← back to CelebritySignatures
screenrecord/game-debug.mjs
409 lines
/**
* screenrecord debug agent — CelebritySignatures /game page
* 5 runs, each with a DISTINCT click-order combination across the 6 game types.
* Reads debug-log.jsonl first (prior errors inform run 4 ordering).
* Appends one JSONL line per action. Converts .webm -> .mp4 via ffmpeg.
*
* Ticket: TK-10192
* Target: http://127.0.0.1:9919/game
* Games: whose, art, match, early, century, lightning
*/
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
process.env.NODE_PATH = process.env.HOME + '/.npm-global/lib/node_modules';
const { chromium } = require(process.env.HOME + '/.npm-global/lib/node_modules/playwright');
const PAGE_URL = 'http://127.0.0.1:9919/game';
const LOG_FILE = path.join(__dirname, 'debug-log.jsonl');
const REC_DIR = path.join(__dirname, 'rec');
const SHOT_DIR = path.join(__dirname, 'screenshots');
// ---- prior log ---
const priorLines = fs.existsSync(LOG_FILE)
? fs.readFileSync(LOG_FILE,'utf8').trim().split('\n').filter(Boolean).map(l=>{try{return JSON.parse(l);}catch{return null;}}).filter(Boolean)
: [];
const priorErrors = priorLines.filter(r=>r.errors && r.errors.length>0);
const priorErrorLabels = new Set(priorErrors.map(e=>e.label||''));
const priorGameErrors = priorErrors.filter(e=>(e.label||'').includes('lightning') || (e.label||'').includes('reveal'));
console.log(`Prior log lines: ${priorLines.length}, prior errors: ${priorErrors.length}`);
console.log(`Prior lightning/reveal errors: ${priorGameErrors.length}`);
function appendLog(obj) {
fs.appendFileSync(LOG_FILE, JSON.stringify(obj) + '\n');
}
// ---- 6 game types ----
const ALL_GAMES = [
{ key:'whose', label:'Whose Hand?' },
{ key:'art', label:'Who Signed This?' },
{ key:'match', label:'Match the Hand' },
{ key:'early', label:'Early or Late?' },
{ key:'century', label:'Date the Hand' },
{ key:'lightning',label:'Lightning Round' },
];
// ---- 5 distinct orderings ----
function orderFor(run) {
if (run === 0) return [...ALL_GAMES]; // DOM order
if (run === 1) return [...ALL_GAMES].reverse(); // reverse
if (run === 2) { // signature-based first, then visual/art
const sigs = ALL_GAMES.filter(g=>['whose','early','century','lightning'].includes(g.key));
const visual = ALL_GAMES.filter(g=>['art','match'].includes(g.key));
return [...sigs, ...visual];
}
if (run === 3) { // seeded shuffle
const s = [...ALL_GAMES];
for (let i = s.length-1; i > 0; i--) {
const j = (i*7 + run*13) % (i+1);
[s[i], s[j]] = [s[j], s[i]];
}
return s;
}
if (run === 4) { // errored-first: lightning led prior errors
const errored = ALL_GAMES.filter(g=>priorGameErrors.some(e=>(e.label||'').includes(g.key)));
const unerrored = ALL_GAMES.filter(g=>!errored.some(e=>e.key===g.key));
return [...errored, ...unerrored];
}
return [...ALL_GAMES];
}
// ---- ffmpeg convert ----
function convertToMp4(webmPath) {
const mp4 = webmPath.replace('.webm','.mp4');
try {
execSync(`ffmpeg -y -i "${webmPath}" -c:v libx264 -preset fast -crf 22 "${mp4}" 2>/dev/null`);
return mp4;
} catch(e) {
return null;
}
}
// ---- helper: wait for element with timeout ----
async function waitForVisible(page, sel, timeout=8000) {
try {
await page.waitForSelector(sel, { state:'visible', timeout });
return true;
} catch {
return false;
}
}
// ---- play one game: select type, click Begin, play N rounds, return result ----
async function playGame(page, gameKey, gameLabel, errs, run, shotDir) {
const ts = () => new Date().toISOString();
const log = (sel, label, action, ok, effect, extraErrs=[]) => {
const snapshot = errs.splice(0);
appendLog({ run, ts: ts(), selector: sel, label, action, ok, effect, errors:[...snapshot,...extraErrs] });
return snapshot;
};
console.log(` [game:${gameKey}] selecting...`);
// --- click the game card ---
try {
await page.locator(`button.gcard[data-g="${gameKey}"]`).click();
await page.waitForTimeout(300);
const active = await page.evaluate(k=>document.querySelector(`button.gcard[data-g="${k}"]`)?.classList.contains('on'), gameKey);
log(`button.gcard[data-g="${gameKey}"]`, `game-select-${gameKey}`, 'click', active, `active=${active}`);
console.log(` [game:${gameKey}] card selected active=${active}`);
} catch(e) {
log(`button.gcard[data-g="${gameKey}"]`, `game-select-${gameKey}`, 'click', false, null, [String(e)]);
console.log(` [game:${gameKey}] ERROR selecting: ${e.message}`);
return { ok: false, error: String(e) };
}
// --- screenshot: game selected, before Begin ---
const shotBefore = path.join(shotDir, `run${run}-${gameKey}-before-begin.png`);
try { await page.screenshot({ path: shotBefore, fullPage: false }); } catch{}
// --- click Begin ---
console.log(` [game:${gameKey}] clicking Begin...`);
try {
await page.locator('#goBtn').click();
// wait for stage to have content (game loaded)
// art/match games fetch external APIs — give up to 25s
const stageLoaded = await waitForVisible(page, '#stage img, #stage .duo, #stage span.tag2', 25000);
const stageHtml = await page.locator('#stage').innerHTML().catch(()=>'');
const choicesCount = await page.locator('.choice').count().catch(()=>0);
const prepText = await page.locator('#prep').textContent().catch(()=>'');
const errorsAfter = await page.locator('#prep').evaluate(el=>el.textContent).catch(()=>'');
log('#goBtn', `game-begin-${gameKey}`, 'click', stageLoaded, `stageLoaded=${stageLoaded} choices=${choicesCount} stage="${stageHtml.slice(0,80)}" prep="${prepText}"`);
console.log(` [game:${gameKey}] begin: loaded=${stageLoaded} choices=${choicesCount} prep="${prepText.trim()}"`);
if (!stageLoaded) {
// screenshot the error/prep state
const shotErr = path.join(shotDir, `run${run}-${gameKey}-begin-fail.png`);
try { await page.screenshot({ path: shotErr, fullPage: false }); } catch{}
return { ok: false, error: `stage did not load: prep="${prepText}"` };
}
} catch(e) {
log('#goBtn', `game-begin-${gameKey}`, 'click', false, null, [String(e)]);
console.log(` [game:${gameKey}] ERROR begin: ${e.message}`);
return { ok: false, error: String(e) };
}
// --- screenshot: first round loaded ---
const shotRound1 = path.join(shotDir, `run${run}-${gameKey}-round1.png`);
try { await page.screenshot({ path: shotRound1, fullPage: false }); } catch{}
// --- play up to 3 rounds (or until finish screen) ---
let roundsPlayed = 0;
let roundErrors = [];
const maxRounds = gameKey === 'lightning' ? 5 : 3;
for (let r = 0; r < maxRounds; r++) {
// For lightning, there's a timer — pick quickly
if (gameKey === 'lightning') {
// wait for choices to appear
try {
await page.waitForSelector('.choice', { state:'visible', timeout:8000 });
} catch(e) {
roundErrors.push(`round${r+1}: no choices appeared: ${e.message}`);
break;
}
// pick first choice (fast, before 5s timer)
try {
await page.locator('.choice').first().click({ timeout: 4500 });
await page.waitForTimeout(600);
const revealHtml = await page.locator('#reveal').innerHTML().catch(()=>'');
log('.choice', `lightning-round-${r+1}-pick`, 'click', !!revealHtml, `reveal="${revealHtml.slice(0,80)}"`);
roundsPlayed++;
// check for "Missing reveal link" bug
if (!revealHtml.trim()) {
roundErrors.push(`Missing reveal content in lightning round ${r+1}`);
}
// check for nextBtn or more choices (auto-advance in lightning)
const hasNext = await page.locator('#nextBtn').count().catch(()=>0);
if (hasNext) {
// click next between artist and title sub-rounds
await page.locator('#nextBtn').click().catch(()=>{});
await page.waitForTimeout(400);
}
} catch(e) {
roundErrors.push(`lightning round${r+1} pick error: ${e.message}`);
log('.choice', `lightning-round-${r+1}-pick`, 'click', false, null, [String(e)]);
break;
}
} else {
// Non-lightning: wait for choices, pick one
try {
await page.waitForSelector('.choice', { state:'visible', timeout:10000 });
} catch(e) {
roundErrors.push(`round${r+1}: no choices appeared: ${e.message}`);
break;
}
try {
// Pick a choice (vary index per round for realism)
const choices = await page.locator('.choice').all();
const idx = r % Math.max(choices.length, 1);
if (choices.length === 0) {
roundErrors.push(`round${r+1}: 0 choices found`);
break;
}
await choices[idx].scrollIntoViewIfNeeded();
await choices[idx].click({ timeout: 5000 });
await page.waitForTimeout(800);
const revealHtml = await page.locator('#reveal').innerHTML().catch(()=>'');
const hasNextBtn = await page.locator('#nextBtn').count().catch(()=>0) > 0;
const hasFinalScore = await page.locator('#finalScore').isVisible().catch(()=>false);
log('.choice', `${gameKey}-round-${r+1}-pick`, 'click', !!revealHtml, `reveal="${revealHtml.slice(0,80)}" hasNext=${hasNextBtn} finished=${hasFinalScore}`);
roundsPlayed++;
if (hasFinalScore) {
console.log(` [game:${gameKey}] reached final score screen`);
break;
}
if (hasNextBtn) {
await page.locator('#nextBtn').click({ timeout:3000 }).catch(()=>{});
await page.waitForTimeout(600);
}
} catch(e) {
roundErrors.push(`round${r+1} error: ${e.message}`);
log('.choice', `${gameKey}-round-${r+1}-pick`, 'click', false, null, [String(e)]);
break;
}
}
// screenshot after round
if (r === 0 || r === maxRounds-1) {
const shotR = path.join(shotDir, `run${run}-${gameKey}-round${r+1}-after.png`);
try { await page.screenshot({ path: shotR, fullPage: false }); } catch{}
}
}
// final screenshot for this game
const shotFinal = path.join(shotDir, `run${run}-${gameKey}-final.png`);
try { await page.screenshot({ path: shotFinal, fullPage: false }); } catch{}
const score = await page.locator('#score').textContent().catch(()=>'?');
const finalScore = await page.locator('#finalScore').textContent().catch(()=>'');
console.log(` [game:${gameKey}] done — rounds=${roundsPlayed} score=${score} final="${finalScore}" errors=${roundErrors.length}`);
appendLog({ run, ts: ts(), selector:'game-summary', label:`game-summary-${gameKey}`, action:'summary', ok: roundErrors.length===0, effect:`rounds=${roundsPlayed} score=${score} finalScore="${finalScore}"`, errors: roundErrors });
return { ok: roundErrors.length===0, roundsPlayed, score, finalScore, errors: roundErrors };
}
// ---- MAIN ----
async function main() {
console.log(`\n=== CelebritySignatures /game debug agent ===`);
console.log(`Target: ${PAGE_URL}`);
console.log(`Runs: 5 (0=dom-order, 1=reverse, 2=sig-first, 3=shuffle, 4=errored-first)\n`);
const allResults = [];
for (let run = 0; run < 5; run++) {
const gameOrder = orderFor(run);
const runDir = path.join(REC_DIR, `game-run${run}`);
const shotDir = path.join(SHOT_DIR);
fs.mkdirSync(runDir, { recursive:true });
fs.mkdirSync(shotDir, { recursive:true });
console.log(`\n--- RUN ${run} [${gameOrder.map(g=>g.key).join(', ')}] ---`);
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width:1400, height:900 },
recordVideo: { dir: runDir, size: { width:1400, height:900 } },
// block GA but not the app itself
extraHTTPHeaders: {},
});
// block google-analytics (noise, not bugs)
await ctx.route('**/*.google-analytics.com/**', route => route.abort());
await ctx.route('**/pagead2.googlesyndication.com/**', route => route.abort());
await ctx.route('**/googletagmanager.com/**', route => route.abort());
const page = await ctx.newPage();
// A single unhandled native dialog modally blocks the reused page and poisons every subsequent
// game's Begin click (30s timeouts logged as false "broken"). Dismiss + record so verdicts are honest.
const dialogsSeen = [];
page.on('dialog', d => { dialogsSeen.push(d.message()); d.dismiss().catch(()=>{}); });
// collect real errors (post-GA-block)
const errs = [];
page.on('console', msg => {
if (msg.type() === 'error') errs.push(`CONSOLE_ERR: ${msg.text()}`);
});
page.on('pageerror', e => errs.push(`PAGEERROR: ${String(e)}`));
page.on('requestfailed', req => {
const url = req.url();
// skip analytics/ads (blocked above, but just in case)
if (/google-analytics|googlesyndication|googletagmanager/.test(url)) return;
errs.push(`REQUEST_FAILED: ${req.method()} ${url} — ${req.failure()?.errorText||'?'}`);
});
const ts = () => new Date().toISOString();
// --- navigate to /game ---
try {
await page.goto(PAGE_URL, { waitUntil:'domcontentloaded', timeout:15000 });
await page.waitForTimeout(1500); // let /api/signatures fetch settle
} catch(e) {
appendLog({ run, ts:ts(), selector:'navigate', label:'page-load', action:'goto', ok:false, effect:null, errors:[String(e)] });
console.log(` FAIL page load: ${e.message}`);
await ctx.close(); await browser.close();
allResults.push({ run, ok:false, gameOrder, error:String(e) });
continue;
}
const pageTitle = await page.title().catch(()=>'?');
const hasGamePick = await page.locator('#gamePick').count().catch(()=>0) > 0;
const hasGoBtn = await page.locator('#goBtn').count().catch(()=>0) > 0;
const navErrs = errs.splice(0);
appendLog({ run, ts:ts(), selector:'/', label:'page-load', action:'goto', ok:hasGamePick, effect:`title="${pageTitle}" hasGamePick=${hasGamePick} hasGoBtn=${hasGoBtn}`, errors:navErrs });
console.log(` page loaded: title="${pageTitle}" gamePick=${hasGamePick} goBtn=${hasGoBtn}`);
// screenshot: /game hub
const shotHub = path.join(shotDir, `run${run}-game-hub.png`);
try { await page.screenshot({ path: shotHub, fullPage: false }); } catch{}
// --- check all 6 game cards present ---
const cardCount = await page.locator('#gamePick .gcard').count().catch(()=>0);
appendLog({ run, ts:ts(), selector:'#gamePick .gcard', label:'game-cards-present', action:'check', ok:cardCount===6, effect:`count=${cardCount}`, errors:errs.splice(0) });
console.log(` game cards: ${cardCount}/6`);
// --- check goBtn text ---
const goBtnText = await page.locator('#goBtn').textContent().catch(()=>'');
appendLog({ run, ts:ts(), selector:'#goBtn', label:'go-btn-present', action:'check', ok:goBtnText.includes('Begin'), effect:`text="${goBtnText}"`, errors:errs.splice(0) });
console.log(` goBtn: "${goBtnText}"`);
// --- play each game in this run's order ---
const runGameResults = {};
for (const game of gameOrder) {
// navigate back to /game before each game to reset state
try {
await page.goto(PAGE_URL, { waitUntil:'domcontentloaded', timeout:15000 });
await page.waitForTimeout(1200);
} catch(e) {
appendLog({ run, ts:ts(), selector:'navigate', label:`nav-for-${game.key}`, action:'goto', ok:false, effect:null, errors:[String(e)] });
console.log(` FAIL nav for ${game.key}: ${e.message}`);
runGameResults[game.key] = { ok:false, error:String(e) };
continue;
}
const result = await playGame(page, game.key, game.label, errs, run, shotDir);
runGameResults[game.key] = result;
}
// --- final page errors ---
const remaining = errs.splice(0);
if (remaining.length > 0) {
appendLog({ run, ts:ts(), selector:'page', label:'end-of-run-errors', action:'check', ok:false, effect:null, errors:remaining });
console.log(` end-of-run errors (${remaining.length}): ${remaining.slice(0,3).map(e=>e.slice(0,80)).join('; ')}`);
}
// close context → saves video
await ctx.close();
await browser.close();
// convert webm -> mp4
const webmFiles = fs.readdirSync(runDir).filter(f=>f.endsWith('.webm'));
const mp4s = [];
for (const wf of webmFiles) {
const webmPath = path.join(runDir, wf);
console.log(` converting ${wf}...`);
const mp4 = convertToMp4(webmPath);
if (mp4) { console.log(` -> ${mp4}`); mp4s.push(mp4); }
else console.log(` (no ffmpeg, leaving .webm)`);
}
const runResult = { run, gameOrder: gameOrder.map(g=>g.key), gameResults: runGameResults, recordings: mp4s.length ? mp4s : webmFiles.map(f=>path.join(runDir,f)) };
allResults.push(runResult);
// summary
const worked = Object.entries(runGameResults).filter(([,v])=>v.ok).map(([k])=>k);
const broken = Object.entries(runGameResults).filter(([,v])=>!v.ok).map(([k,v])=>`${k}(${v.error||v.errors?.join(';')})`.slice(0,80));
console.log(` run ${run} done — worked: [${worked.join(', ')}] broken: [${broken.join(', ')}]`);
}
console.log('\n=== All runs complete ===');
return allResults;
}
main().then(results => {
console.log('\nRun summaries:');
for (const r of results) {
if (r.error) { console.log(` run${r.run}: FATAL ${r.error}`); continue; }
const worked = Object.entries(r.gameResults||{}).filter(([,v])=>v.ok).map(([k])=>k);
const broken = Object.entries(r.gameResults||{}).filter(([,v])=>!v.ok).map(([k])=>k);
console.log(` run${r.run} [${r.gameOrder?.join(',')}]: worked=[${worked}] broken=[${broken}] recordings=${(r.recordings||[]).length}`);
}
console.log('\nLog:', LOG_FILE);
process.exit(0);
}).catch(e => {
console.error('Fatal:', e);
process.exit(1);
});