← back to CelebritySignatures
scripts/screenrecord-game.mjs
526 lines
/**
* screenrecord-game.mjs — 5-run Playwright screen recorder for /game
* Covers all 6 games: whose, art, match, early, century, lightning
* Each run uses a DISTINCT click-order combination.
* Appends to screenrecord/debug-log.jsonl after every action.
*/
import pkg from '/Users/macstudio3/.npm-global/lib/node_modules/playwright/index.js';
const { chromium } = pkg;
import fs from 'fs';
import path from 'path';
const BASE = '/Users/macstudio3/Projects/CelebritySignatures';
const LOG = path.join(BASE, 'screenrecord/debug-log.jsonl');
const URL = 'http://localhost:9920/game';
// Read prior runs
const prior = fs.existsSync(LOG)
? fs.readFileSync(LOG,'utf8').trim().split('\n').filter(Boolean).map(l=>{try{return JSON.parse(l);}catch{return null;}}).filter(Boolean)
: [];
const priorErrors = prior.filter(r => r.errors && r.errors.length > 0);
console.log(`[screenrecord] Prior log entries: ${prior.length}, with errors: ${priorErrors.length}`);
const append = obj => fs.appendFileSync(LOG, JSON.stringify(obj)+'\n');
// The 6 games + difficulties to test per run
const GAMES = ['whose','art','match','early','century','lightning'];
const DIFFS = ['icons','scholar','curator','deep'];
const CATS = ['All','Artists','Authors','Politics'];
// Per-run: which game+difficulty+category combos to try (deterministic seeds)
function runsFor(runIdx) {
if (runIdx === 0) {
// DOM order: all 6 games, icons difficulty, All category
return GAMES.map(g => ({ game:g, diff:'icons', cat:'All' }));
}
if (runIdx === 1) {
// Reverse order, scholar difficulty
return [...GAMES].reverse().map(g => ({ game:g, diff:'scholar', cat:'All' }));
}
if (runIdx === 2) {
// Art-first (art-based games first), curator difficulty; vary categories
const artFirst = ['art','match','lightning','whose','century','early'];
return artFirst.map((g,i) => ({ game:g, diff:'curator', cat: g==='whose'||g==='century' ? CATS[i%CATS.length] : 'All' }));
}
if (runIdx === 3) {
// Seeded shuffle: icons; hit errored games first
const erroredGames = new Set(priorErrors.map(e=>e.game).filter(Boolean));
const sorted = [...GAMES].sort((a,b)=>(erroredGames.has(b)?1:0)-(erroredGames.has(a)?1:0));
// then shuffle rest with seed
const rest = sorted.slice(erroredGames.size);
for(let i=rest.length-1;i>0;i--){const j=(i*7+3*13)%(i+1);[rest[i],rest[j]]=[rest[j],rest[i]];}
const combo = [...sorted.slice(0,erroredGames.size), ...rest];
return combo.map(g => ({ game:g, diff:'icons', cat: g==='whose'?'Artists':g==='century'?'Politics':'All' }));
}
if (runIdx === 4) {
// Re-hit everything that errored + deep cuts on those, icons on stable ones
const erroredGames = new Set(priorErrors.map(e=>e.game).filter(Boolean));
return GAMES.map(g => ({ game:g, diff: erroredGames.has(g)?'deep':'icons', cat:'All' }));
}
return GAMES.map(g => ({ game:g, diff:'icons', cat:'All' }));
}
async function wait(ms) { return new Promise(r=>setTimeout(r,ms)); }
async function runGame(page, errs, run, {game: gameKey, diff, cat}) {
const ts = () => new Date().toISOString();
const errors = () => { const e=[...errs]; errs.length=0; return e; };
async function logAction(selector, label, action, ok, effect, extraErrors=[]) {
const e = [...errors(), ...extraErrors];
const entry = { run, ts:ts(), game:gameKey, diff, cat, selector, label, action, ok, effect, errors:e };
append(entry);
if (e.length) console.log(` [ERRORS] ${e.join(' | ')}`);
}
console.log(`\n [RUN ${run}] Game: ${gameKey} | Diff: ${diff} | Cat: ${cat}`);
// Navigate fresh
try {
await page.goto(URL, { waitUntil:'domcontentloaded', timeout:15000 });
await wait(800);
} catch(e) {
await logAction('page','navigation','goto', false, 'Navigation failed', [String(e)]);
return { game:gameKey, ok:false, error:'Navigation failed' };
}
// ── 1. Select game card ──
const cardSel = `#gamePick .gcard[data-g="${gameKey}"]`;
try {
await page.locator(cardSel).scrollIntoViewIfNeeded();
const beforeClass = await page.locator(cardSel).getAttribute('class');
await page.locator(cardSel).click();
await wait(200);
const afterClass = await page.locator(cardSel).getAttribute('class');
const on = afterClass?.includes('on') ?? false;
await logAction(cardSel, `game-card-${gameKey}`, 'click', on, on?'card selected':'card not selected');
if (!on) {
return { game:gameKey, ok:false, error:'Game card did not activate' };
}
} catch(e) {
await logAction(cardSel, `game-card-${gameKey}`, 'click', false, 'Click failed', [String(e)]);
return { game:gameKey, ok:false, error:String(e) };
}
// ── 2. Verify category row visibility ──
const catRowVisible = gameKey==='whose' || gameKey==='century';
try {
const catLabel = page.locator('#catLabel');
const catOpts = page.locator('#catOpts');
const labelDisplay = await catLabel.evaluate(el => getComputedStyle(el).display);
const optsDisplay = await catOpts.evaluate(el => getComputedStyle(el).display);
const visible = labelDisplay !== 'none' && optsDisplay !== 'none';
const expected = catRowVisible;
const ok = visible === expected;
await logAction('#catLabel/#catOpts', 'category-row-visibility', 'inspect', ok,
`catLabel=${labelDisplay}, catOpts=${optsDisplay}, expected_visible=${expected}, actual=${visible}`);
if (!ok) {
await logAction('#catLabel/#catOpts', 'category-row-visibility', 'inspect', false,
`REGRESSION: category row visible=${visible} but expected=${expected} for game=${gameKey}`,
[`Category row visibility bug for ${gameKey}`]);
}
} catch(e) {
await logAction('#catLabel', 'category-row-visibility', 'inspect', false, 'Check failed', [String(e)]);
}
// ── 3. Select difficulty ──
const diffSel = `#diffOpts .opt[data-d="${diff}"]`;
try {
await page.locator(diffSel).scrollIntoViewIfNeeded();
await page.locator(diffSel).click();
await wait(150);
const on = await page.locator(diffSel).evaluate(el=>el.classList.contains('on'));
await logAction(diffSel, `diff-${diff}`, 'click', on, on?'selected':'not selected');
} catch(e) {
await logAction(diffSel, `diff-${diff}`, 'click', false, 'Click failed', [String(e)]);
}
// ── 4. Select category (if applicable) ──
if (catRowVisible) {
const catSel = `#catOpts .opt[data-c="${cat}"]`;
try {
await page.locator(catSel).scrollIntoViewIfNeeded();
await page.locator(catSel).click();
await wait(150);
const on = await page.locator(catSel).evaluate(el=>el.classList.contains('on'));
await logAction(catSel, `cat-${cat}`, 'click', on, on?'selected':'not selected');
} catch(e) {
await logAction(catSel, `cat-${cat}`, 'click', false, 'Click failed', [String(e)]);
}
}
// ── 5. Click Begin ──
let alertMsg = null;
page.once('dialog', async dialog => {
alertMsg = dialog.message();
await dialog.dismiss();
});
const beginT = Date.now();
try {
await page.locator('#goBtn').scrollIntoViewIfNeeded();
await page.locator('#goBtn').click();
await logAction('#goBtn', 'begin-button', 'click', true, 'Begin clicked');
} catch(e) {
await logAction('#goBtn', 'begin-button', 'click', false, 'Click failed', [String(e)]);
return { game:gameKey, ok:false, error:String(e) };
}
// Wait for either play panel or alert (error)
let started = false;
let prepTextLog = [];
for (let i=0; i<60; i++) {
// Check for alert (error condition)
if (alertMsg) {
await logAction('#goBtn', 'begin-result', 'wait', false, `Alert: ${alertMsg}`, [`game start failed: ${alertMsg}`]);
return { game:gameKey, ok:false, error:alertMsg };
}
// Check if play panel appeared
try {
const playHidden = await page.locator('#play').getAttribute('hidden');
if (playHidden === null) { started = true; break; }
} catch {}
// Log prep text
try {
const prepHidden = await page.locator('#prep').getAttribute('hidden');
if (prepHidden === null) {
const txt = await page.locator('#prep').textContent();
if (txt && !prepTextLog.includes(txt)) { prepTextLog.push(txt); console.log(` prep: ${txt}`); }
}
} catch {}
await wait(500);
}
if (!started) {
const took = Date.now()-beginT;
await logAction('#play', 'play-panel-appeared', 'wait', false, `Timeout after ${took}ms. Alert: ${alertMsg||'none'}`, [alertMsg||'Timeout waiting for play panel']);
return { game:gameKey, ok:false, error: alertMsg || 'Timeout waiting for play panel' };
}
const loadTime = Date.now()-beginT;
await logAction('#play', 'play-panel-appeared', 'wait', true, `play panel shown in ${loadTime}ms`);
// ── 6. Play through all rounds ──
let roundsPlayed = 0;
let finished = false;
let lightning_timer_tested = false;
let lightning_timeout_seen = false;
let lightning_pairs_checked = false;
let stage_renders = 0;
let choices_renders = 0;
let images_found = 0;
let images_broken = 0;
let timer_appeared = false;
let next_btn_worked = 0;
for (let roundN = 0; roundN < 25; roundN++) {
// Check if results panel appeared
try {
const resultsHidden = await page.locator('#results').getAttribute('hidden');
if (resultsHidden === null) { finished = true; break; }
} catch {}
// Check if play panel still visible
try {
const playHidden = await page.locator('#play').getAttribute('hidden');
if (playHidden !== null) { break; }
} catch {}
// --- Inspect stage ---
let stageHtml = '';
try {
stageHtml = await page.locator('#stage').innerHTML();
if (stageHtml.trim().length > 0) stage_renders++;
} catch(e) {
await logAction('#stage', `round-${roundN+1}-stage`, 'inspect', false, 'Stage inspect failed', [String(e)]);
}
// Check for images in stage
try {
const imgs = await page.locator('#stage img').all();
for (const img of imgs) {
const src = await img.getAttribute('src') || '';
const naturalWidth = await img.evaluate(el => el.naturalWidth);
if (naturalWidth > 0) images_found++;
else {
images_broken++;
await logAction(src, `round-${roundN+1}-image`, 'inspect', false, `Image broken/404: ${src}`, [`Image 404 or broken: ${src}`]);
}
}
} catch {}
// Check for timer bar (lightning only)
if (gameKey==='lightning') {
try {
const timerHidden = await page.locator('#timerbar').getAttribute('hidden');
if (timerHidden === null) {
timer_appeared = true;
// Inspect the fill class
const fillClass = await page.locator('#tbFill').getAttribute('class');
await logAction('#timerbar', `round-${roundN+1}-timer`, 'inspect', true, `Timer bar visible, fill class: ${fillClass}`);
// On first lightning round (artist question), let it time out
if (!lightning_timer_tested) {
lightning_timer_tested = true;
console.log(` [LIGHTNING] Letting timer expire for round ${roundN+1}...`);
await wait(5500); // wait 5.5s for timeout
// Check reveal for "⏱ Time!"
const revealText = await page.locator('#reveal').textContent().catch(()=>'');
lightning_timeout_seen = revealText.includes('Time!');
await logAction('#reveal', `round-${roundN+1}-timer-out`, 'wait', lightning_timeout_seen,
`Reveal after timeout: "${revealText.slice(0,100)}"`,
lightning_timeout_seen ? [] : ['Timer did not produce Time! in reveal']);
// Check choices are disabled
const choicesDisabled = await page.locator('.choice').evaluateAll(btns => btns.every(b=>b.disabled));
await logAction('.choice', `round-${roundN+1}-choices-disabled`, 'inspect', choicesDisabled,
`Choices disabled after timeout: ${choicesDisabled}`,
choicesDisabled ? [] : ['Choices not disabled after timer expired']);
}
} else {
if (gameKey==='lightning') {
// Timer is hidden — might be between rounds. Only flag if in an active round.
const revealEmpty = (await page.locator('#reveal').textContent().catch(()=>'')).trim().length === 0;
if (revealEmpty && roundN > 0) {
await logAction('#timerbar', `round-${roundN+1}-timer-hidden`, 'inspect', false,
'Timer bar hidden during active lightning round with empty reveal',
['Lightning timer bar not showing for active round']);
}
}
}
} catch(e) {
await logAction('#timerbar', `round-${roundN+1}-timer`, 'inspect', false, 'Timer check failed', [String(e)]);
}
}
// --- Inspect choices ---
let choiceCount = 0;
try {
const choices = await page.locator('.choice').all();
choiceCount = choices.length;
if (choiceCount > 0) choices_renders++;
else {
await logAction('#choices', `round-${roundN+1}-choices`, 'inspect', false, 'No choices rendered',
['No choice buttons rendered for round '+roundN]);
}
} catch(e) {
await logAction('#choices', `round-${roundN+1}-choices`, 'inspect', false, 'Choice inspect failed', [String(e)]);
}
await logAction('#stage', `round-${roundN+1}-render`, 'inspect', stage_renders > roundN,
`stage_html_len=${stageHtml.length}, choices=${choiceCount}, imgs_ok=${images_found}, imgs_broken=${images_broken}`);
// --- Click a choice ---
try {
const choices = await page.locator('.choice:not([disabled])').all();
if (choices.length === 0) {
// Maybe already waiting for next, click next btn
const nextBtn = page.locator('#nextBtn');
const nextVisible = await nextBtn.isVisible().catch(()=>false);
if (nextVisible) {
await nextBtn.click();
await wait(300);
continue;
}
await logAction('.choice', `round-${roundN+1}-pick`, 'click', false, 'No enabled choices found');
await wait(500);
continue;
}
// Pick first available choice for most rounds, last for some variety
const pickIdx = (roundN % 3 === 0) ? 0 : (roundN % 3 === 1) ? choices.length-1 : Math.floor(choices.length/2);
const chosen = choices[Math.min(pickIdx, choices.length-1)];
const choiceText = await chosen.textContent().catch(()=>'');
await chosen.scrollIntoViewIfNeeded();
await chosen.click();
await wait(400);
const revealText = await page.locator('#reveal').textContent().catch(()=>'');
const revealHasContent = revealText.trim().length > 0;
await logAction('.choice', `round-${roundN+1}-pick-${pickIdx}`, 'click', revealHasContent,
`Chose: "${choiceText.slice(0,40)}", reveal: "${revealText.slice(0,80)}"`);
roundsPlayed++;
} catch(e) {
await logAction('.choice', `round-${roundN+1}-pick`, 'click', false, 'Choice click failed', [String(e)]);
}
// --- Check for reveal link in art/match/lightning ---
if (['art','match','lightning'].includes(gameKey)) {
try {
const revealLink = await page.locator('#reveal a').first().getAttribute('href').catch(()=>null);
if (!revealLink) {
await logAction('#reveal a', `round-${roundN+1}-reveal-link`, 'inspect', false,
'No reveal link found after pick',
[`Missing reveal link in ${gameKey} round ${roundN+1}`]);
} else {
await logAction('#reveal a', `round-${roundN+1}-reveal-link`, 'inspect', true,
`Reveal link: ${revealLink.slice(0,80)}`);
}
} catch {}
}
// --- Click Next → ───
await wait(200);
try {
const nextBtn = page.locator('#nextBtn');
const nextVisible = await nextBtn.isVisible().catch(()=>false);
if (nextVisible) {
const nextText = await nextBtn.textContent().catch(()=>'');
await nextBtn.scrollIntoViewIfNeeded();
await nextBtn.click();
next_btn_worked++;
await logAction('#nextBtn', `round-${roundN+1}-next`, 'click', true, `Next text: "${nextText}"`);
await wait(400);
} else {
// Wait a bit — might be loading
await wait(1000);
const nextBtn2 = page.locator('#nextBtn');
const nextVisible2 = await nextBtn2.isVisible().catch(()=>false);
if (nextVisible2) {
await nextBtn2.click();
next_btn_worked++;
await logAction('#nextBtn', `round-${roundN+1}-next`, 'click', true, 'Next appeared after 1s');
await wait(400);
} else {
await logAction('#nextBtn', `round-${roundN+1}-next`, 'click', false, 'Next button not visible after pick+wait');
}
}
} catch(e) {
await logAction('#nextBtn', `round-${roundN+1}-next`, 'click', false, 'Next click failed', [String(e)]);
}
}
// ── 7. Check results screen ──
let resultsOk = false;
let resultsScore = null;
let resultsVerdict = null;
let resultsBest = null;
let lightningBonusLine = null;
try {
const resultsHidden = await page.locator('#results').getAttribute('hidden');
resultsOk = resultsHidden === null;
} catch {}
if (resultsOk) {
try { resultsScore = await page.locator('#finalScore').textContent(); } catch {}
try { resultsVerdict = await page.locator('#verdict').textContent(); } catch {}
try { resultsBest = await page.locator('#best').textContent(); } catch {}
if (gameKey==='lightning') {
try { lightningBonusLine = await page.locator('#misses').innerHTML(); } catch {}
}
await logAction('#results', 'results-screen', 'inspect', resultsOk,
`score=${resultsScore}, verdict="${resultsVerdict}", best="${resultsBest}"`);
if (gameKey==='lightning') {
const hasBonusLine = (lightningBonusLine||'').includes('Named BOTH');
await logAction('#misses', 'lightning-bonus-line', 'inspect', hasBonusLine,
`Bonus line HTML: "${(lightningBonusLine||'').slice(0,200)}"`,
hasBonusLine ? [] : ['Lightning bonus "Named BOTH on X paintings" line missing from results']);
}
// Check best score stored
if (!resultsBest || !resultsBest.includes(gameKey)) {
await logAction('#best', 'best-score', 'inspect', false,
`Best score text missing game key: "${resultsBest}"`,
['Best score line does not include game key']);
}
} else {
await logAction('#results', 'results-screen', 'inspect', false, `Results panel did not appear after ${roundsPlayed} rounds`,
['Results screen did not appear']);
}
// ── 8. Play again ──
if (resultsOk) {
try {
await page.locator('#againBtn').scrollIntoViewIfNeeded();
await page.locator('#againBtn').click();
await wait(400);
const setupHidden = await page.locator('#setup').getAttribute('hidden');
const setupVisible = setupHidden === null;
await logAction('#againBtn', 'play-again', 'click', setupVisible,
`Setup panel returned: ${setupVisible}`,
setupVisible ? [] : ['Play again did not return to setup panel']);
} catch(e) {
await logAction('#againBtn', 'play-again', 'click', false, 'Play again click failed', [String(e)]);
}
}
return {
game: gameKey, ok: resultsOk, roundsPlayed, stage_renders, choices_renders,
images_found, images_broken, timer_appeared, lightning_timeout_seen,
next_btn_worked, resultsScore, resultsVerdict, resultsBest, lightningBonusLine
};
}
async function main() {
for (let run=0; run<5; run++) {
console.log(`\n${'='.repeat(60)}\n[RUN ${run}] Starting...\n${'='.repeat(60)}`);
const TK_AGENT = process.env.TK_AGENT || 'screenrecord';
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 1600, height: 900 },
recordVideo: { dir: path.join(BASE, `screenrecord/rec/run${run}`), size: { width: 1600, height: 900 } }
});
const page = await ctx.newPage();
const errs = [];
page.on('console', m => { if (m.type()==='error') errs.push(`CONSOLE_ERR: ${m.text()}`); });
page.on('pageerror', e => errs.push(`PAGE_ERR: ${e.message||String(e)}`));
page.on('requestfailed', req => {
const url = req.url();
if (!url.includes('gtag') && !url.includes('fonts.googleapis')) {
errs.push(`REQUEST_FAILED: ${req.method()} ${url} — ${req.failure()?.errorText||'unknown'}`);
}
});
page.on('response', resp => {
const url = resp.url();
if (resp.status() >= 400 && !url.includes('gtag')) {
errs.push(`HTTP_${resp.status()}: ${url}`);
}
});
const combos = runsFor(run);
const runResults = [];
for (const combo of combos) {
errs.length = 0; // Reset per game
try {
const result = await runGame(page, errs, run, combo);
runResults.push(result);
console.log(` [RESULT] ${combo.game}: ok=${result.ok}, rounds=${result.roundsPlayed||0}, errors=${result.error||'none'}`);
} catch(e) {
console.error(` [FATAL] ${combo.game}: ${e.message}`);
append({ run, ts:new Date().toISOString(), game:combo.game, diff:combo.diff, cat:combo.cat,
selector:'top-level', label:'fatal', action:'run', ok:false, effect:'fatal error', errors:[String(e)] });
runResults.push({ game:combo.game, ok:false, error:String(e) });
}
}
await ctx.close();
await browser.close();
// Convert .webm -> .mp4
const recDir = path.join(BASE, `screenrecord/rec/run${run}`);
const webms = fs.readdirSync(recDir).filter(f=>f.endsWith('.webm'));
for (const webm of webms) {
const src = path.join(recDir, webm);
const dst = src.replace('.webm','.mp4');
try {
const { execSync } = await import('child_process');
execSync(`/opt/homebrew/bin/ffmpeg -y -i "${src}" -c:v libx264 -preset fast -crf 22 "${dst}" 2>/dev/null`, {timeout:120000});
fs.unlinkSync(src);
console.log(` [ffmpeg] ${webm} -> .mp4`);
} catch(fe) {
console.log(` [ffmpeg] conversion failed for ${webm}: ${fe.message?.slice(0,80)}`);
}
}
console.log(`\n[RUN ${run}] Complete. Results:`, runResults.map(r=>`${r.game}:${r.ok?'OK':'FAIL'}`).join(', '));
}
console.log('\n[screenrecord] All 5 runs complete. Log:', LOG);
}
main().catch(e => { console.error('[FATAL]', e); process.exit(1); });