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