← back to Dead Agentabrams

scripts/screenrecord-5pass.js

566 lines

'use strict';
// 5-pass screen recording + click-every-element debug agent
// dead.agentabrams.com/room/ — Grateful Dead music room
// NODE_PATH=$HOME/.npm-global/lib/node_modules node scripts/screenrecord-5pass.js

const { chromium } = require('playwright');
const fs   = require('fs');
const path = require('path');
const { execSync } = require('child_process');

const URL        = 'https://dead.agentabrams.com/room/';
const CHROME_EXE = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const LOG        = '/Users/macstudio3/Projects/dead-agentabrams/screenrecord/debug-log.jsonl';
const REC_BASE   = '/Users/macstudio3/Projects/dead-agentabrams/screenrecord/rec';

fs.mkdirSync(path.dirname(LOG), { recursive: true });
for (let r = 0; r < 5; r++) fs.mkdirSync(path.join(REC_BASE, `run${r}`), { recursive: true });

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 = new Set(prior.filter(r => r.errors && r.errors.length > 0).map(r => r.selector));
console.log(`Prior log entries: ${prior.length}, prior errored selectors: ${priorErrors.size}`);

function append(obj) {
  fs.appendFileSync(LOG, JSON.stringify(obj) + '\n');
}

function seededShuffle(arr, seed) {
  const s = [...arr];
  let state = seed;
  for (let i = s.length - 1; i > 0; i--) {
    state = (state * 1664525 + 1013904223) & 0xffffffff;
    const j = Math.abs(state) % (i + 1);
    [s[i], s[j]] = [s[j], s[i]];
  }
  return s;
}

function orderFor(run, els) {
  switch(run) {
    case 0: return [...els];
    case 1: return [...els].reverse();
    case 2: return [...els].sort((a, b) => (b.type === 'range' ? 1 : 0) - (a.type === 'range' ? 1 : 0));
    case 3: return seededShuffle(els, 42 * 13 + run * 7);
    case 4: {
      const bad = sel => priorErrors.has(sel) ? 0 : 1;
      return [...els].sort((a, b) => bad(a.selector) - bad(b.selector));
    }
  }
}

async function runPass(runIdx) {
  console.log(`\n===== RUN ${runIdx} =====`);
  const recDir = path.join(REC_BASE, `run${runIdx}`);

  const browser = await chromium.launch({
    executablePath: CHROME_EXE,
    headless: true,
    args: ['--no-sandbox']
  });

  const ctx = await browser.newContext({
    viewport: { width: 1600, height: 900 },
    recordVideo: { dir: recDir, size: { width: 1600, height: 900 } }
  });

  const page = await ctx.newPage();
  const consoleErrors = [];
  const pageErrors = [];
  const archiveRequests = [];
  const archiveErrors = [];

  page.on('console', m => {
    const txt = m.text().slice(0, 300);
    if (m.type() === 'error') consoleErrors.push(txt);
    if (txt.includes('archive') || txt.includes('fetch') || txt.includes('Error')) {
      consoleErrors.push(`[console:${m.type()}] ${txt}`);
    }
  });
  page.on('pageerror', e => pageErrors.push(('' + e).slice(0, 300)));
  page.on('requestfailed', req => {
    if (req.url().includes('archive.org')) {
      archiveErrors.push({ url: req.url().slice(0, 120), err: req.failure()?.errorText });
    }
  });
  page.on('response', resp => {
    if (resp.url().includes('archive.org')) {
      archiveRequests.push({ url: resp.url().slice(0, 120), status: resp.status() });
    }
  });

  const loadStart = Date.now();
  await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
  const domReadyMs = Date.now() - loadStart;
  await page.waitForTimeout(2000);

  // Capture bar heights at load (BEFORE archive.org fills them)
  const barsAtLoad = await page.evaluate(() => {
    const bars = Array.from(document.querySelectorAll('.mr-yr'));
    return {
      count: bars.length,
      sampleStyles: bars.slice(0, 3).map(b => ({ style: b.style.height, computed: window.getComputedStyle(b).height }))
    };
  });

  append({ run: runIdx, ts: new Date().toISOString(), selector: 'PAGE_LOAD', label: 'page load',
    action: 'navigate', ok: true,
    effect: `domReady=${domReadyMs}ms barsAtLoad=${JSON.stringify(barsAtLoad)}`,
    errors: [...consoleErrors.splice(0), ...pageErrors.splice(0)] });

  // ---- Step 1: Open hamburger ----
  let hamburgerOk = false;
  try {
    await page.click('#chromeBtn', { force: true, timeout: 5000 });
    await page.waitForTimeout(800);
    hamburgerOk = true;
  } catch(e) {
    append({ run: runIdx, ts: new Date().toISOString(), selector: '#chromeBtn', label: 'hamburger',
      action: 'click', ok: false, effect: 'FAILED: ' + e.message.slice(0,100), errors: [] });
  }
  append({ run: runIdx, ts: new Date().toISOString(), selector: '#chromeBtn', label: 'hamburger ☰ Controls',
    action: 'click', ok: hamburgerOk, effect: `panel_revealed=${hamburgerOk}`,
    errors: [...consoleErrors.splice(0)] });

  await page.screenshot({ path: path.join(recDir, '01-after-hamburger.png') });

  // ---- Discover all visible elements after panel open ----
  // IMPORTANT: We do NOT include #mrToggle in click list (it would collapse the music room section)
  const rawEls = await page.evaluate(() => {
    const result = [];
    const seen = new Set();

    document.querySelectorAll('button, [role=button], input, select, a[href]').forEach(el => {
      const id = el.id;
      const cls = el.className.toString().trim();
      const key = id ? `#${id}` : (cls ? `${el.tagName.toLowerCase()}.${cls.split(/\s+/).join('.')}` : null);
      if (!key || seen.has(key)) return;
      seen.add(key);

      const rect = el.getBoundingClientRect();
      if (rect.width === 0 && rect.height === 0) return; // skip invisible

      result.push({
        selector: id ? `#${id}` : `${el.tagName.toLowerCase()}.${cls.split(/\s+/)[0]}`,
        label: el.textContent?.trim().slice(0, 50) || el.id || el.placeholder || 'unlabeled',
        type: el.tagName === 'INPUT' ? el.type : (el.tagName === 'SELECT' ? 'select' : 'button'),
        rect: { x: Math.round(rect.x), y: Math.round(rect.y), w: Math.round(rect.width), h: Math.round(rect.height) }
      });
    });

    return result;
  });

  // Filter out navigation links (would leave page), and mrToggle (would collapse music room section)
  const els = rawEls.filter(e =>
    e.selector !== '#backlink' &&
    e.selector !== '#mrToggle' &&
    e.selector !== '#chromeBtn'  // already clicked
  );

  console.log(`  Run ${runIdx}: ${els.length} elements to interact with`);

  // ---- Year bar measurements BEFORE clicking ----
  const yearBarMeasure = await page.evaluate(() => {
    const bars = Array.from(document.querySelectorAll('.mr-yr'));
    const containerEl = bars[0]?.parentElement;
    if (!containerEl) return { found: false };
    const cRect = containerEl.getBoundingClientRect();
    const barRects = bars.map(b => {
      const r = b.getBoundingClientRect();
      return { text: b.textContent?.trim(), x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
    });
    return {
      found: true,
      containerW: Math.round(cRect.width),
      containerScrollW: containerEl.scrollWidth,
      overflows: containerEl.scrollWidth > cRect.width + 2,
      barCount: bars.length,
      firstBar: barRects[0],
      lastBar: barRects[barRects.length - 1],
      allBarsW11: barRects.every(b => b.w === 11),
      narrowBars: barRects.filter(b => b.w < 20).length,
      sampleRects: barRects.slice(0, 3)
    };
  });

  append({ run: runIdx, ts: new Date().toISOString(), selector: 'YEAR_MAP_MEASURE',
    label: 'year bar measurements', action: 'measure', ok: true,
    effect: JSON.stringify(yearBarMeasure), errors: [] });

  console.log(`  Year bars: count=${yearBarMeasure.barCount} overflow=${yearBarMeasure.overflows} narrow=${yearBarMeasure.narrowBars} first=${JSON.stringify(yearBarMeasure.firstBar)}`);

  // ---- "All years" button size ----
  const allBtnMeasure = await page.evaluate(() => {
    const btn = document.querySelector('#mrAll');
    if (!btn) return { found: false };
    const r = btn.getBoundingClientRect();
    return { found: true, w: Math.round(r.width), h: Math.round(r.height), x: Math.round(r.x), y: Math.round(r.y), text: btn.textContent?.trim() };
  });

  append({ run: runIdx, ts: new Date().toISOString(), selector: '#mrAll:measure',
    label: '"All years" button size', action: 'measure', ok: true,
    effect: JSON.stringify(allBtnMeasure), errors: [] });

  // ---- Seek slider height ----
  const seekMeasure = await page.evaluate(() => {
    const el = document.querySelector('#mrSeek');
    if (!el) return { found: false };
    const r = el.getBoundingClientRect();
    return { found: true, w: Math.round(r.width), h: Math.round(r.height) };
  });

  append({ run: runIdx, ts: new Date().toISOString(), selector: '#mrSeek:measure',
    label: 'seek slider dimensions', action: 'measure', ok: true,
    effect: JSON.stringify(seekMeasure), errors: [] });

  // ---- Header / label visibility check ----
  const headerCheck = await page.evaluate(() => {
    const header = document.querySelector('.mr-header, [class*=header]');
    const label = document.querySelector('[aria-label*="Touring"], [aria-label*="year"], h2, h3');
    const allEl = document.querySelector('#mrAll');
    const searchEl = document.querySelector('#mrSearch');
    return {
      hasHeader: !!header,
      headerText: header?.textContent?.trim().slice(0, 80),
      hasLabel: !!label,
      labelText: label?.textContent?.trim().slice(0, 80),
      allBtnFound: !!allEl,
      searchFound: !!searchEl,
      bodyHTML_sample: document.querySelector('.musicroom')?.innerHTML?.slice(0, 600)
    };
  });

  append({ run: runIdx, ts: new Date().toISOString(), selector: 'HEADER_CHECK',
    label: 'header/label visibility', action: 'measure', ok: true,
    effect: JSON.stringify(headerCheck), errors: [] });

  // ---- Apply ordering for this run ----
  const ordered = orderFor(runIdx, els);

  // ---- Click/interact every element ----
  for (const el of ordered) {
    const errsBefore = consoleErrors.length;
    let ok = false;
    let effect = '';
    let action = 'click';
    let errMsg = '';

    // Snapshot before for key elements
    let beforeState = '';
    if (el.selector === '#mrAll') {
      beforeState = await page.evaluate(() => {
        const b = document.querySelector('#mrAll');
        return b ? `cls=${b.className} text=${b.textContent?.trim()}` : '';
      });
    }

    try {
      if (el.type === 'range') {
        action = 'drag';
        // Click at center, then at 80% position using coordinates
        const cx = el.rect.x + el.rect.w * 0.5;
        const cy = el.rect.y + el.rect.h * 0.5;
        const r8 = el.rect.x + el.rect.w * 0.8;
        await page.mouse.click(cx, cy);
        await page.waitForTimeout(100);
        await page.mouse.click(r8, cy);
        await page.waitForTimeout(100);
        const val = await page.evaluate(sel => { const e = document.querySelector(sel); return e?.value; }, el.selector);
        effect = `dragged box=${el.rect.w}x${el.rect.h} val=${val}`;
        ok = true;
      } else if (el.type === 'search' || el.selector === '#mrSearch') {
        action = 'type';
        await page.mouse.click(el.rect.x + el.rect.w * 0.5, el.rect.y + el.rect.h * 0.5);
        await page.waitForTimeout(200);
        await page.keyboard.type('1972');
        await page.waitForTimeout(600);
        const srchResult = await page.evaluate(() => {
          const sel = document.querySelector('#mrShow');
          return sel ? `${Array.from(sel.options).length} options visible` : 'select not found';
        });
        effect = `typed '1972', results: ${srchResult}`;
        // Clear
        await page.keyboard.selectAll();
        await page.keyboard.press('Backspace');
        await page.waitForTimeout(300);
        ok = true;
      } else if (el.type === 'select') {
        action = 'select';
        const optCount = await page.evaluate(sel => {
          const s = document.querySelector(sel);
          return s ? Array.from(s.options).length : 0;
        }, el.selector);
        effect = `${optCount} options`;
        if (optCount > 1) {
          // Pick index 1 (second item)
          await page.selectOption(el.selector, { index: 1 });
          await page.waitForTimeout(500);
        }
        ok = true;
      } else {
        // Click by coordinates (bypasses scrollIntoView stability checks)
        const cx = el.rect.x + el.rect.w * 0.5;
        const cy = el.rect.y + el.rect.h * 0.5;
        if (cx < 0 || cy < 0 || cx > 1600 || cy > 900) {
          effect = `out of viewport at (${cx},${cy}) -- skipped`;
          ok = false;
        } else {
          await page.mouse.click(cx, cy);
          await page.waitForTimeout(350);
          effect = `clicked at (${Math.round(cx)},${Math.round(cy)}) box=${el.rect.w}x${el.rect.h}`;
          ok = true;
        }
      }
    } catch(e) {
      errMsg = e.message.slice(0, 200);
      effect = `ERROR: ${errMsg}`;
    }

    // Capture after-state
    let afterState = '';
    if (el.selector === '#mrAll') {
      afterState = await page.evaluate(() => {
        const b = document.querySelector('#mrAll');
        const yi = document.querySelector('#mrYear');
        return b ? `cls=${b.className} text=${b.textContent?.trim()} yearVal=${yi?.value}` : '';
      });
    } else if (el.selector && el.selector.match(/mrPlay|playBtn/)) {
      afterState = await page.evaluate(() => {
        const mp = document.querySelector('#mrPlay');
        return mp ? `mrPlay=${mp.textContent?.trim()}` : '';
      });
    }

    const newErrs = consoleErrors.slice(errsBefore);
    const newPageErrs = [...pageErrors.splice(0)];
    const allNewErrs = [...newErrs, ...newPageErrs, ...(errMsg ? [errMsg] : [])];

    const logEntry = {
      run: runIdx,
      ts: new Date().toISOString(),
      selector: el.selector,
      label: el.label,
      action,
      ok,
      effect: [effect, afterState ? `after:${afterState}` : '', beforeState ? `before:${beforeState}` : ''].filter(Boolean).join(' | '),
      errors: allNewErrs
    };

    if (allNewErrs.length) {
      console.log(`  [R${runIdx}] ERR  ${el.label.slice(0,30)}: ${allNewErrs[0].slice(0,80)}`);
    } else {
      console.log(`  [R${runIdx}] OK   ${el.label.slice(0,30)}: ${effect.slice(0, 60)}`);
    }

    append(logEntry);

    // After clicking a year bar, record state
    if (el.selector && (el.selector.includes('mr-yr') || el.label.match(/196[0-9]|197[0-9]|198[0-9]|199[0-9]/))) {
      const yrState = await page.evaluate(() => {
        const allBtn = document.querySelector('#mrAll');
        const bars = Array.from(document.querySelectorAll('.mr-yr'));
        const active = bars.filter(b => b.classList.contains('active') || b.getAttribute('aria-pressed') === 'true');
        return {
          allBtnCls: allBtn?.className,
          activeCount: active.length,
          activeTexts: active.map(b => b.textContent?.trim())
        };
      });
      append({ run: runIdx, ts: new Date().toISOString(),
        selector: el.selector + ':yrstate', label: 'year state snapshot',
        action: 'snapshot', ok: true, effect: JSON.stringify(yrState), errors: [] });
    }
  }

  // ---- Screenshot mid-run ----
  await page.screenshot({ path: path.join(recDir, '02-mid-run.png') });

  // ---- Wait for archive.org bar height animation ----
  console.log(`  Waiting 6s for archive.org bar heights to load...`);
  const waitStart = Date.now();
  await page.waitForTimeout(6000);

  const barsAfterWait = await page.evaluate(() => {
    const bars = Array.from(document.querySelectorAll('.mr-yr'));
    const barSpans = Array.from(document.querySelectorAll('.mr-yr .bar, .mr-yr > span'));
    return {
      barCount: bars.length,
      sampleParentStyles: bars.slice(0, 5).map(b => ({ style: b.style.height, computed: window.getComputedStyle(b).height })),
      spanCount: barSpans.length,
      sampleSpanHeights: barSpans.slice(0, 5).map(s => window.getComputedStyle(s).height),
      hasRealHeights: bars.some(b => b.style.height && b.style.height !== '0px' && b.style.height !== 'auto'),
      archiveDataAttr: bars.slice(0,3).map(b => ({
        dataCount: b.getAttribute('data-count'),
        dataYear: b.textContent?.trim()
      }))
    };
  });

  append({ run: runIdx, ts: new Date().toISOString(), selector: 'BAR_HEIGHT_TIMING',
    label: `bar heights after ${Math.round((Date.now() - waitStart + 6000) / 1000)}s wait`,
    action: 'measure', ok: true, effect: JSON.stringify(barsAfterWait),
    errors: archiveErrors.map(e => `archive FAIL: ${e.url} ${e.err}`) });

  console.log(`  Archive reqs: ${archiveRequests.length} ok, ${archiveErrors.length} fail`);
  console.log(`  Bars have real heights: ${barsAfterWait.hasRealHeights}`);

  // ---- Click a specific year bar to test selection feedback ----
  const bar1980 = await page.evaluate(() => {
    const bars = Array.from(document.querySelectorAll('.mr-yr'));
    const b = bars.find(b => b.textContent?.trim() === '1980') || bars[15];
    if (!b) return null;
    const r = b.getBoundingClientRect();
    return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
  });

  if (bar1980) {
    const cx = bar1980.x + bar1980.w * 0.5;
    const cy = bar1980.y + bar1980.h * 0.5;
    console.log(`  Clicking 1980 bar at (${cx},${cy}) size ${bar1980.w}x${bar1980.h}`);

    // Take screenshot before click
    await page.screenshot({ path: path.join(recDir, '03-before-year-click.png') });

    await page.mouse.click(cx, cy);
    await page.waitForTimeout(1000);

    // Check selection feedback
    const selectionFeedback = await page.evaluate(() => {
      const bars = Array.from(document.querySelectorAll('.mr-yr'));
      const active = bars.filter(b => b.classList.contains('active') || b.getAttribute('aria-pressed') === 'true' || b.style.opacity === '1' || b.style.background);
      const resultList = document.querySelector('#mrList, [id*=List], [class*=result]');
      const header = document.querySelector('.mr-header, h3, .section-label');
      return {
        activeBarCount: active.length,
        activeBarTexts: active.map(b => b.textContent?.trim()),
        resultItems: resultList ? resultList.querySelectorAll('option, li, .item').length : 0,
        hasVisualFeedback: active.length > 0,
        headerText: header?.textContent?.trim()
      };
    });

    append({ run: runIdx, ts: new Date().toISOString(), selector: '.mr-yr[1980]',
      label: 'click 1980 bar — selection feedback', action: 'click', ok: true,
      effect: JSON.stringify(selectionFeedback), errors: [...consoleErrors.splice(0)] });

    console.log(`  Selection feedback: ${JSON.stringify(selectionFeedback)}`);

    await page.screenshot({ path: path.join(recDir, '04-after-year-click.png') });

    // Click "All years" to reset
    const allRect = await page.evaluate(() => {
      const b = document.querySelector('#mrAll');
      if (!b) return null;
      const r = b.getBoundingClientRect();
      return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
    });

    if (allRect) {
      await page.mouse.click(allRect.x + allRect.w * 0.5, allRect.y + allRect.h * 0.5);
      await page.waitForTimeout(500);
      const afterReset = await page.evaluate(() => {
        const b = document.querySelector('#mrAll');
        const yi = document.querySelector('#mrYear');
        return { allBtnCls: b?.className, yearVal: yi?.value };
      });
      append({ run: runIdx, ts: new Date().toISOString(), selector: '#mrAll:reset',
        label: 'All years reset after 1980 click', action: 'click', ok: true,
        effect: JSON.stringify(afterReset), errors: [] });
    }
  }

  // ---- State leak check: reload and measure ----
  // (done only on run 2 to check if selected year persists across reload)
  if (runIdx === 2) {
    const stateBeforeReload = await page.evaluate(() => {
      return {
        mrYearVal: document.querySelector('#mrYear')?.value,
        allBtnCls: document.querySelector('#mrAll')?.className,
        localStorageKeys: Object.keys(localStorage)
      };
    });

    await page.reload({ waitUntil: 'domcontentloaded', timeout: 20000 });
    await page.waitForTimeout(2000);
    // Reopen panel
    await page.click('#chromeBtn', { force: true, timeout: 5000 }).catch(() => {});
    await page.waitForTimeout(500);

    const stateAfterReload = await page.evaluate(() => {
      return {
        mrYearVal: document.querySelector('#mrYear')?.value,
        allBtnCls: document.querySelector('#mrAll')?.className,
        localStorageKeys: Object.keys(localStorage)
      };
    });

    append({ run: runIdx, ts: new Date().toISOString(), selector: 'STATE_LEAK_CHECK',
      label: 'state leak: reload test', action: 'reload', ok: true,
      effect: `before=${JSON.stringify(stateBeforeReload)} after=${JSON.stringify(stateAfterReload)}`,
      errors: [] });
    console.log(`  State leak check: before=${JSON.stringify(stateBeforeReload)} after=${JSON.stringify(stateAfterReload)}`);
  }

  // ---- Final screenshot ----
  await page.screenshot({ path: path.join(recDir, '05-final.png') });

  await ctx.close();
  await browser.close();

  // Convert webm -> mp4
  const recFiles = fs.readdirSync(recDir).filter(f => f.endsWith('.webm'));
  const mp4Files = [];
  for (const webm of recFiles) {
    const webmPath = path.join(recDir, webm);
    const mp4Path  = webmPath.replace('.webm', '.mp4');
    try {
      execSync(`ffmpeg -y -i "${webmPath}" -c:v libx264 -preset fast -crf 28 "${mp4Path}" 2>/dev/null`, { timeout: 90000 });
      mp4Files.push(mp4Path);
      console.log(`  Converted: ${path.basename(mp4Path)}`);
    } catch(e) {
      console.log(`  ffmpeg failed: ${e.message.slice(0,80)}`);
      mp4Files.push(webmPath); // keep webm reference
    }
  }

  return {
    runIdx, recDir,
    recordings: mp4Files,
    archiveRequests: archiveRequests.length,
    archiveErrors: archiveErrors.length,
    archiveStatus: barsAfterWait,
    yearBarMeasure,
    allBtnMeasure,
    seekMeasure,
    headerCheck
  };
}

(async () => {
  const results = [];
  for (let r = 0; r < 5; r++) {
    const res = await runPass(r);
    results.push(res);
    if (r < 4) await new Promise(resolve => setTimeout(resolve, 1500));
  }

  console.log('\n===== ALL 5 RUNS COMPLETE =====');
  for (const res of results) {
    console.log(`Run ${res.runIdx}: archive=${res.archiveRequests} reqs/${res.archiveErrors} errs recs=${res.recordings.map(p => path.basename(p))}`);
  }

  fs.writeFileSync(
    '/Users/macstudio3/Projects/dead-agentabrams/screenrecord/run-results.json',
    JSON.stringify(results, null, 2)
  );
  console.log('\nLog: ' + LOG);
  console.log('Results: /Users/macstudio3/Projects/dead-agentabrams/screenrecord/run-results.json');
})().catch(e => {
  console.error('FATAL:', e.message, e.stack?.slice(0, 400));
  process.exit(1);
});