← back to Crazy News Channel

scripts/screenrecord.js

411 lines

// screenrecord.js — click-everything, 5-run, 5-order debug harness for
// crazy-news-channel/index.html. See ~/.claude/skills/screenrecord/SKILL.md.
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');

const ROOT = '/Users/macstudio3/Projects/crazy-news-channel';
const URL = 'file://' + ROOT + '/index.html';
const LOG = path.join(ROOT, 'screenrecord', 'debug-log.jsonl');
const REC_DIR = path.join(ROOT, 'screenrecord', 'rec');

const prior = fs.existsSync(LOG)
  ? fs.readFileSync(LOG, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l))
  : [];
const priorErrorSelectors = new Set(
  prior.filter((r) => r.errors && r.errors.length).map((r) => r.selector)
);

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

function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
function seededShuffle(arr, seed) {
  const rnd = mulberry32(seed);
  const a = [...arr];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rnd() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

async function snap(page) {
  return page.evaluate(() => {
    const body = document.body;
    const adminPanel = document.getElementById('adminPanel');
    const articleView = document.getElementById('articleView');
    const klaxonBtn = document.getElementById('klaxonBtn');
    const gridView = document.getElementById('gridView');
    const headlineEl = articleView && articleView.querySelector('.article-headline');
    return {
      hash: location.hash,
      mood: body.getAttribute('data-mood'),
      breakingMode: body.classList.contains('breaking-mode'),
      klaxonPressed: klaxonBtn ? klaxonBtn.getAttribute('aria-pressed') : null,
      klaxonText: klaxonBtn ? klaxonBtn.textContent.trim() : null,
      adminHidden: adminPanel ? adminPanel.hidden : null,
      adminExpanded: document.getElementById('adminToggleBtn')
        ? document.getElementById('adminToggleBtn').getAttribute('aria-expanded')
        : null,
      articleHidden: articleView ? articleView.hidden : null,
      articleHeadline: headlineEl ? headlineEl.textContent.trim().slice(0, 70) : null,
      gridHidden: gridView ? gridView.hidden : null,
      adminStoryCount: document.getElementById('adminStoryCount')
        ? document.getElementById('adminStoryCount').textContent
        : null,
      storyCardCount: document.querySelectorAll('#storyGrid .story-card').length,
      focused: document.activeElement
        ? document.activeElement.tagName + (document.activeElement.id ? '#' + document.activeElement.id : '')
        : null,
      soundChecked: document.getElementById('soundToggle')
        ? document.getElementById('soundToggle').checked
        : null,
    };
  });
}

function effectDiff(before, after) {
  const diffs = [];
  for (const k of Object.keys(after)) {
    if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
      diffs.push(`${k}: ${JSON.stringify(before[k])} -> ${JSON.stringify(after[k])}`);
    }
  }
  return diffs.length ? diffs.join('; ') : 'no visible state change';
}

async function run5Runs() {
  const summary = { runs: [], skipped: [] };

  for (let runIdx = 0; runIdx < 5; runIdx++) {
    const recDir = path.join(REC_DIR, `run${runIdx}`);
    fs.mkdirSync(recDir, { recursive: true });

    const browser = await chromium.launch();
    const ctx = await browser.newContext({
      viewport: { width: 1440, height: 900 },
      recordVideo: { dir: recDir, size: { width: 1440, height: 900 } },
    });
    const page = await ctx.newPage();
    // Generous timeouts: this host is a heavily-loaded shared dev machine
    // (load avg ~48 observed during a prior pass caused widespread
    // false-positive timeouts unrelated to the app). 10s/6s gives the app
    // room to respond under contention so remaining failures are signal,
    // not host noise.
    page.setDefaultTimeout(10000);

    // The app's tick() loop re-renders #storyGrid from scratch whenever ANY
    // of the 40 staggered stories advances its escalation stage (roughly
    // every 1-2s) — this can detach a just-located story-card element out
    // from under a click (a real, timing-dependent bug, not a harness
    // artifact). Retry once with a fresh locator so the harness doesn't
    // burn 30s per hit while still recording the failure if it persists.
    async function clickWithRetry(selector) {
      let lastErr;
      for (let attempt = 0; attempt < 3; attempt++) {
        try {
          const el = page.locator(selector).first();
          await el.scrollIntoViewIfNeeded({ timeout: 6000 });
          await el.click({ timeout: 6000 });
          return;
        } catch (e) {
          lastErr = e;
          await page.waitForTimeout(200);
        }
      }
      throw lastErr;
    }

    let errBuf = [];
    page.on('console', (m) => { if (m.type() === 'error') errBuf.push('console: ' + m.text()); });
    page.on('pageerror', (e) => errBuf.push('pageerror: ' + String(e)));

    async function act(selector, label, action, clickFn) {
      let before, after, ok = true, exMsg = null;
      try {
        before = await snap(page);
        await clickFn();
        await page.waitForTimeout(300);
        after = await snap(page);
      } catch (e) {
        ok = false;
        exMsg = String(e).split('\n')[0];
        after = before || {};
      }
      const errors = errBuf.splice(0);
      const entry = {
        run: runIdx,
        ts: new Date().toISOString(),
        selector,
        label,
        action,
        ok: ok && errors.length === 0,
        effect: exMsg ? `EXCEPTION: ${exMsg}` : effectDiff(before, after),
        errors,
      };
      append(entry);
      if (errors.length || !ok) priorErrorSelectors.add(selector);
      return entry;
    }

    await page.goto(URL, { waitUntil: 'domcontentloaded' });
    await page.waitForTimeout(400);
    // initial-load snapshot line (not a click, but establishes the baseline
    // per run so the log shows fresh-context state before any interaction)
    append({
      run: runIdx,
      ts: new Date().toISOString(),
      selector: 'document',
      label: `run${runIdx} initial load (file://index.html)`,
      action: 'goto',
      ok: true,
      effect: JSON.stringify(await snap(page)),
      errors: errBuf.splice(0),
    });

    // ---------- discover story-open-id targets in DOM order ----------
    const openIds = await page.$$eval('[data-open-id]', (els) =>
      [...new Set(els.map((e) => e.getAttribute('data-open-id')))]
    );
    const catOrder = ['all', 'politics', 'weather', 'scitech', 'sports', 'entertainment', 'business', 'uncategorized'];

    // sample of story cards to open/read/back — bounded for runtime;
    // full 40-card enumeration is logged as skipped below.
    const sampleIds = (idsInOrder) => {
      const n = idsInOrder.length;
      if (n <= 4) return idsInOrder;
      return [idsInOrder[0], idsInOrder[Math.floor(n / 3)], idsInOrder[Math.floor((2 * n) / 3)], idsInOrder[n - 1]];
    };

    // ---------- reusable step blocks ----------
    const blockStoryCards = async (idsInOrder) => {
      const ids = sampleIds(idsInOrder);
      for (const id of ids) {
        await act(`[data-open-id="${id}"]`, `open story card "${id}"`, 'click', async () => {
          await clickWithRetry(`[data-open-id="${id}"]`);
        });
        await act('#articleView [data-back]', `back-to-grid from article "${id}"`, 'click', async () => {
          const back = page.locator('#articleView [data-back]').first();
          if (await back.count().catch(() => 0)) await back.click({ timeout: 6000 }).catch(() => {});
          else throw new Error('back button not present (article likely never opened)');
        });
      }
    };

    const blockCategories = async (orderedCats) => {
      for (const cat of orderedCats) {
        await act(`label[for="cat-${cat}"]`, `category mood filter -> ${cat}`, 'click', async () => {
          await page.locator(`label[for="cat-${cat}"]`).click();
        });
      }
    };

    const blockKlaxon = async (times) => {
      for (let i = 0; i < times; i++) {
        await act('#klaxonBtn', `klaxon toggle (${i % 2 === 0 ? 'ON' : 'OFF'})`, 'click', async () => {
          await page.locator('#klaxonBtn').click();
        });
      }
    };

    const blockSoundToggle = async () => {
      await act('#soundToggle', 'sound toggle ON', 'click', async () => {
        await page.locator('#soundToggle').click();
      });
      await act('#soundToggle', 'sound toggle OFF', 'click', async () => {
        await page.locator('#soundToggle').click();
      });
    };

    const blockAdminOpen = async () => {
      await act('#adminToggleBtn', 'open admin panel', 'click', async () => {
        await page.locator('#adminToggleBtn').click();
      });
    };
    const blockAdminClose = async () => {
      await act('#adminToggleBtn', 'close admin panel (toggle)', 'click', async () => {
        await page.locator('#adminToggleBtn').click();
      });
    };
    const blockAdminCreate = async (tag) => {
      await act('#adminHeadline', `admin create story (empty headline validation) [${tag}]`, 'submit', async () => {
        await page.locator('#adminHeadline').fill('');
        await page.locator('#adminCreateForm button[type=submit]').click();
      });
      const headline = `Screenrecord Test Story ${tag} ${Date.now()}`;
      await act('#adminCreateForm', `admin create story "${headline}" [${tag}]`, 'submit', async () => {
        await page.locator('#adminHeadline').fill(headline);
        await page.locator('#adminCreateForm button[type=submit]').click();
      });
      return headline;
    };
    const blockAdminDelete = async (headline) => {
      await act(`[data-delete-id]:has-text("Delete")`, `admin delete story created "${headline}"`, 'click', async () => {
        const row = page.locator('.admin-story-row', { hasText: headline.slice(0, 30) }).first();
        const btn = row.locator('[data-delete-id]');
        if (await btn.count()) await btn.click();
        else {
          // fallback: delete first row in list
          await page.locator('.admin-story-list [data-delete-id]').first().click();
        }
      });
    };
    const blockAdminReset = async () => {
      await act('#adminResetBtn', 'admin reset to defaults', 'click', async () => {
        await page.locator('#adminResetBtn').click();
      });
    };

    const blockEscapeFromArticle = async (id) => {
      await act(`[data-open-id="${id}"]`, `open story "${id}" (for escape test)`, 'click', async () => {
        await clickWithRetry(`[data-open-id="${id}"]`);
      });
      await act('document', 'Escape key from article view', 'keydown', async () => {
        await page.keyboard.press('Escape');
      });
    };
    const blockEscapeFromAdmin = async () => {
      await blockAdminOpen();
      await act('document', 'Escape key from admin panel', 'keydown', async () => {
        await page.keyboard.press('Escape');
      });
    };
    const blockEscapePriorityBoth = async (id) => {
      // open BOTH admin and an article simultaneously (they are NOT mutually
      // exclusive in the DOM), then verify Escape's stated priority
      // (admin closes first, article closes on 2nd Escape).
      await blockAdminOpen();
      await act(`[data-open-id="${id}"]`, `open article "${id}" while admin panel is ALSO open`, 'click', async () => {
        await clickWithRetry(`[data-open-id="${id}"]`);
      });
      await act('document', 'Escape #1 (both admin+article open — expect admin closes first)', 'keydown', async () => {
        await page.keyboard.press('Escape');
      });
      await act('document', 'Escape #2 (expect article closes now)', 'keydown', async () => {
        await page.keyboard.press('Escape');
      });
    };

    // ---------- order per run ----------
    if (runIdx === 0) {
      // DOM order: story cards -> categories -> klaxon -> admin CRUD -> escape tests
      await blockStoryCards(openIds);
      await blockCategories(catOrder);
      await blockKlaxon(2);
      await blockAdminOpen();
      const h = await blockAdminCreate('run0');
      await blockAdminDelete(h);
      await blockAdminReset();
      await blockAdminClose();
      await blockEscapeFromArticle(openIds[0]);
      await blockEscapeFromAdmin();
    } else if (runIdx === 1) {
      // reverse order
      await blockEscapeFromAdmin();
      await blockEscapeFromArticle(openIds[openIds.length - 1]);
      await blockAdminOpen();
      const h = await blockAdminCreate('run1');
      await blockAdminReset(); // reset BEFORE delete — order-dependent: does reset wipe the just-created row cleanly?
      await blockAdminDelete(h); // expect no-op (already gone via reset) — watch for errors
      await blockAdminClose();
      await blockKlaxon(2);
      await blockCategories([...catOrder].reverse());
      await blockStoryCards([...openIds].reverse());
    } else if (runIdx === 2) {
      // toggles-first (no literal sliders exist in this app; klaxon + sound
      // checkbox are the closest analog — hit them first, extra reps)
      await blockKlaxon(4); // on/off/on/off rapid — watch for siren/interval leak
      await blockSoundToggle();
      await blockKlaxon(2); // klaxon ON+sound ON interplay, then back off
      await blockAdminOpen();
      const h = await blockAdminCreate('run2');
      await blockAdminDelete(h);
      await blockAdminReset();
      await blockAdminClose();
      await blockCategories(catOrder);
      await blockStoryCards(openIds);
      await blockEscapeFromArticle(openIds[1] || openIds[0]);
    } else if (runIdx === 3) {
      // seeded shuffle (seed=3) of the whole block list
      const blocks = [
        () => blockStoryCards(openIds),
        () => blockCategories(catOrder),
        () => blockKlaxon(2),
        () => blockSoundToggle(),
        async () => { const h = await blockAdminCreate('run3'); await blockAdminDelete(h); },
        () => blockAdminReset(),
        () => blockEscapeFromArticle(openIds[2] || openIds[0]),
        () => blockEscapeFromAdmin(),
        () => blockCategories([...catOrder].reverse()),
      ];
      const order = seededShuffle(blocks.map((_, i) => i), 3);
      for (const i of order) await blocks[i]();
    } else if (runIdx === 4) {
      // errored-first: re-hit anything that broke in runs 0-3 first.
      const errored = [...priorErrorSelectors];
      if (errored.length) {
        for (const sel of errored) {
          await act(sel, `re-test previously-errored selector: ${sel}`, 'click-retry', async () => {
            const loc = page.locator(sel).first();
            if (await loc.count()) await loc.click({ timeout: 6000 }).catch(() => {});
          });
        }
      } else {
        // nothing errored in runs 0-3 (clean run) — use run4 to stress the
        // one genuinely order-dependent seam this app exposes: admin panel
        // + article view are NOT mutually exclusive, so test Escape's
        // documented priority under that combined state, both directions.
        await blockEscapePriorityBoth(openIds[0]);
        await blockAdminReset();
        await blockEscapePriorityBoth(openIds[openIds.length - 1]);
      }
      // then full remaining coverage in a fourth distinct order (categories
      // shuffled with a different seed, cards reversed, klaxon+admin last)
      await blockCategories(seededShuffle(catOrder, 4));
      await blockStoryCards(seededShuffle(openIds, 4));
      const h = await blockAdminCreate('run4');
      await blockAdminDelete(h);
      await blockAdminReset();
      await blockKlaxon(2);
    }

    if (openIds.length > 4) {
      append({
        run: runIdx,
        ts: new Date().toISOString(),
        selector: '[data-open-id] (full set)',
        label: `skipped ${openIds.length - 4} of ${openIds.length} story cards for runtime; tested a spread sample (first/1-3/2-3/last) instead of every card`,
        action: 'skip-note',
        ok: true,
        effect: 'bounded coverage, not a defect',
        errors: [],
      });
    }

    await page.waitForTimeout(400);
    await ctx.close();
    await browser.close();
    summary.runs.push({ run: runIdx, recDir });
    console.log(`run${runIdx} done -> ${recDir}`);
  }

  return summary;
}

run5Runs().then((s) => {
  fs.writeFileSync('/tmp/screenrecord-summary.json', JSON.stringify(s, null, 2));
  console.log('ALL RUNS COMPLETE');
}).catch((e) => {
  console.error('HARNESS FAILURE', e);
  process.exit(1);
});