← 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);
});