← back to Crazy News Channel Shadowman

scripts/chaos-screenrecord.js

479 lines

// chaos-screenrecord.js — 5-run, 5-order screen-record harness focused on
// the CHAOS INDEX feature (commits through f961c67), interleaved with the
// existing checks (category filters, admin CRUD, article open/close,
// Escape paths, resets). Appends to the SAME persistent debug-log.jsonl as
// prior screenrecord cycles. 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) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
  : [];
const priorErrorSelectors = new Set(
  prior.filter((r) => r.errors && r.errors.length).map((r) => r.selector)
);
console.log(`[chaos-screenrecord] loaded ${prior.length} prior log lines, ${priorErrorSelectors.size} distinct prior-error selectors`);

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 anchorCaption = document.getElementById('anchorCaption');
    const standbyFlash = document.getElementById('standbyFlash');
    const meltdownBanner = document.getElementById('meltdownBanner');
    const headlineEl = articleView && articleView.querySelector('.article-headline');
    const tierClasses = [0, 1, 2, 3].filter((i) => body.classList.contains('chaos-tier-' + i));
    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,
      concludedCount: document.querySelectorAll('#storyGrid .story-card.concluded').length,
      focused: document.activeElement
        ? document.activeElement.tagName + (document.activeElement.id ? '#' + document.activeElement.id : '')
        : null,
      soundChecked: document.getElementById('soundToggle')
        ? document.getElementById('soundToggle').checked
        : null,
      chaosPct: document.getElementById('chaosPct') ? document.getElementById('chaosPct').textContent : null,
      chaosBandLabel: document.getElementById('chaosBandLabel') ? document.getElementById('chaosBandLabel').textContent : null,
      chaosTierClasses: tierClasses,
      anchorCaptionHidden: anchorCaption ? anchorCaption.hidden : null,
      anchorCaptionText: anchorCaption && !anchorCaption.hidden ? anchorCaption.textContent.trim() : null,
      standbyFlashHidden: standbyFlash ? standbyFlash.hidden : null,
      standbyFlashFlashing: standbyFlash ? standbyFlash.classList.contains('flashing') : null,
      meltdownBannerHidden: meltdownBanner ? meltdownBanner.hidden : null,
      bodyHasOverflowX: document.documentElement.scrollWidth > document.documentElement.clientWidth,
    };
  });
}

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';
}

// ---------- step catalog ----------
// Each step: { id, label, fn(page, ctx) -> effect string (or throws) }
// ctx carries per-run mutable info (created story ids etc).
function buildCatalog() {
  const cats = ['all', 'politics', 'weather', 'scitech', 'sports', 'entertainment', 'business', 'uncategorized'];
  const catSteps = cats.map((c) => ({
    id: `cat-${c}`,
    label: `category mood filter -> ${c}`,
    kind: 'toggle',
    async fn(page) {
      await page.click(`label[for="cat-${c}"]`);
    },
  }));

  const steps = [];

  steps.push({
    id: 'openAdmin', label: 'open admin panel', kind: 'toggle',
    async fn(page) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (hidden) await page.click('#adminToggleBtn');
    },
  });
  steps.push({
    id: 'closeAdmin', label: 'close admin panel', kind: 'toggle',
    async fn(page) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (!hidden) await page.click('#adminToggleBtn');
    },
  });

  steps.push(...catSteps);

  steps.push({
    id: 'klaxonOn', label: 'klaxon toggle ON', kind: 'toggle',
    async fn(page) {
      const pressed = await page.locator('#klaxonBtn').getAttribute('aria-pressed');
      if (pressed !== 'true') await page.click('#klaxonBtn');
    },
  });
  steps.push({
    id: 'klaxonOff', label: 'klaxon toggle OFF', kind: 'toggle',
    async fn(page) {
      const pressed = await page.locator('#klaxonBtn').getAttribute('aria-pressed');
      if (pressed === 'true') await page.click('#klaxonBtn');
    },
  });
  steps.push({
    id: 'klaxonRapid4x', label: 'klaxon rapid on/off/on/off x4 (interval-leak probe)', kind: 'toggle',
    async fn(page) {
      for (let i = 0; i < 4; i++) await page.click('#klaxonBtn');
    },
  });
  steps.push({
    id: 'soundOn', label: 'sound toggle ON', kind: 'toggle',
    async fn(page) {
      const checked = await page.locator('#soundToggle').isChecked();
      if (!checked) await page.click('#soundToggle');
    },
  });
  steps.push({
    id: 'soundOff', label: 'sound toggle OFF', kind: 'toggle',
    async fn(page) {
      const checked = await page.locator('#soundToggle').isChecked();
      if (checked) await page.click('#soundToggle');
    },
  });
  steps.push({
    id: 'sirenCycle', label: 'sound ON + klaxon ON (siren start) then klaxon OFF (siren stop) - audio timer leak probe', kind: 'toggle',
    async fn(page) {
      const soundChecked = await page.locator('#soundToggle').isChecked();
      if (!soundChecked) await page.click('#soundToggle');
      const pressed = await page.locator('#klaxonBtn').getAttribute('aria-pressed');
      if (pressed !== 'true') await page.click('#klaxonBtn');
      await page.waitForTimeout(150);
      await page.click('#klaxonBtn'); // off, siren should stop
      await page.click('#soundToggle'); // back off
    },
  });

  steps.push({
    id: 'openArticle-tuesdays', label: 'open story card "tuesdays"', kind: 'article',
    async fn(page) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (!hidden) await page.click('#adminToggleBtn');
      // Known harness quirk from the prior screenrecord cycle: a non-"all"
      // category filter left set by an earlier step hides most story cards
      // from the DOM, which looks like an app bug but is just the filter
      // correctly doing its job. Defensively reset to "all" before trying
      // to open a specific story by id.
      const checked = await page.locator('#cat-all').isChecked().catch(() => true);
      if (!checked) await page.click('label[for="cat-all"]');
      const el = page.locator('[data-open-id="tuesdays"]').first();
      if (await el.count() === 0) return; // genuinely absent (e.g. deleted), expected
      await el.scrollIntoViewIfNeeded({ timeout: 4000 });
      await el.click({ timeout: 4000 });
    },
  });
  steps.push({
    id: 'closeArticleBack', label: 'back-to-grid from article', kind: 'article',
    async fn(page) {
      const el = page.locator('#articleView [data-back]');
      if (await el.count() && await el.isVisible()) await el.click({ timeout: 4000 });
    },
  });
  steps.push({
    id: 'openArticle-gerald', label: 'open story card "gerald"', kind: 'article',
    async fn(page) {
      const checked = await page.locator('#cat-all').isChecked().catch(() => true);
      if (!checked) await page.click('label[for="cat-all"]');
      const el = page.locator('[data-open-id="gerald"]').first();
      if (await el.count() === 0) return;
      await el.scrollIntoViewIfNeeded({ timeout: 4000 });
      await el.click({ timeout: 4000 });
    },
  });
  steps.push({
    id: 'escapeFromArticle', label: 'Escape key from article view', kind: 'article',
    async fn(page) {
      await page.keyboard.press('Escape');
    },
  });
  steps.push({
    id: 'escapeBothOpenPriority', label: 'admin+article both open, Escape x2 (priority check)', kind: 'article',
    async fn(page) {
      const adminHidden = await page.locator('#adminPanel').isHidden();
      if (adminHidden) await page.click('#adminToggleBtn');
      const checked = await page.locator('#cat-all').isChecked().catch(() => true);
      if (!checked) await page.click('label[for="cat-all"]');
      const el = page.locator('[data-open-id="tuesdays"]').first();
      if (await el.count()) {
        await el.scrollIntoViewIfNeeded({ timeout: 4000 }).catch(() => {});
        await el.click({ timeout: 4000 }).catch(() => {});
      }
      await page.keyboard.press('Escape'); // admin should close first
      await page.keyboard.press('Escape'); // article should close second
    },
  });

  steps.push({
    id: 'adminCreateStory1', label: 'admin create story #1 (chaos-swing-up test)', kind: 'admin-crud',
    async fn(page, ctx) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (hidden) await page.click('#adminToggleBtn');
      const headline = `Chaos Swing Story A run${ctx.run} ${Date.now()}`;
      await page.fill('#adminHeadline', headline, { timeout: 6000 });
      await page.selectOption('#adminCategory', 'scitech');
      await page.click('#adminCreateForm .admin-submit-btn', { timeout: 6000 });
      ctx.createdIds.push(headline);
      ctx.lastCreatedHeadline = headline;
    },
  });
  steps.push({
    id: 'adminCreateStory2', label: 'admin create story #2 (chaos-swing-up test)', kind: 'admin-crud',
    async fn(page, ctx) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (hidden) await page.click('#adminToggleBtn');
      const headline = `Chaos Swing Story B run${ctx.run} ${Date.now()}`;
      await page.fill('#adminHeadline', headline, { timeout: 6000 });
      await page.selectOption('#adminCategory', 'business');
      await page.click('#adminCreateForm .admin-submit-btn', { timeout: 6000 });
      ctx.createdIds.push(headline);
      ctx.lastCreatedHeadline = headline;
    },
  });
  steps.push({
    id: 'adminCreateEmptyValidation', label: 'admin create story (empty headline validation)', kind: 'admin-crud',
    async fn(page) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (hidden) await page.click('#adminToggleBtn');
      await page.fill('#adminHeadline', '', { timeout: 6000 });
      await page.click('#adminCreateForm .admin-submit-btn', { timeout: 6000 });
    },
  });
  steps.push({
    id: 'adminDeleteCreated', label: 'admin delete a created chaos-swing story (chaos-swing-down test)', kind: 'admin-crud',
    async fn(page, ctx) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (hidden) await page.click('#adminToggleBtn');
      if (!ctx.createdIds.length) return; // nothing to delete this pass
      const headline = ctx.createdIds.shift();
      const row = page.locator('.admin-story-row', { hasText: headline });
      if (await row.count() === 0) return; // may already be gone (reset ran earlier)
      await row.locator('[data-delete-id]').first().click({ timeout: 6000 });
    },
  });
  steps.push({
    id: 'adminResetToDefaults', label: 'admin reset to defaults (chaos reset test)', kind: 'admin-crud',
    async fn(page, ctx) {
      const hidden = await page.locator('#adminPanel').isHidden();
      if (hidden) await page.click('#adminToggleBtn');
      await page.click('#adminResetBtn', { timeout: 6000 });
      ctx.createdIds = [];
    },
  });

  // ---- Chaos Index escalation via fake clock ----
  // IMPORTANT: use clock.runFor(), not clock.fastForward(). fastForward()
  // simulates "the machine slept" and fires each due timer AT MOST ONCE at
  // the jump point — a repeating setInterval(tick,1000) would NOT get to
  // fire once per virtual second, so state.elapsedSec would barely move and
  // stories would never escalate. runFor() fires every intermediate timer
  // (like sinon's clock.tick), which is what's needed to actually drive the
  // app's real per-second tick() loop forward. (Confirmed empirically: an
  // earlier fastForward-based version of this harness only moved chaosPct
  // by 1-2% across 500+ virtual seconds — a harness bug, not an app bug.)
  steps.push({
    id: 'chaosFF-tier1', label: 'advance clock ~20s via runFor (expect chaos tier to start rising toward Concerning)', kind: 'chaos',
    async fn(page) { await page.clock.runFor(20 * 1000); },
  });
  steps.push({
    id: 'chaosFF-tier2', label: 'advance clock +90s more via runFor (expect Unprecedented tier / anchor caption)', kind: 'chaos',
    async fn(page) { await page.clock.runFor(90 * 1000); },
  });
  steps.push({
    id: 'chaosFF-tier3', label: 'advance clock +150s more via runFor (expect Post-Journalism tier, ticker stutter, tilt)', kind: 'chaos',
    async fn(page) { await page.clock.runFor(150 * 1000); },
  });
  steps.push({
    id: 'chaosFF-concluded', label: 'advance clock in 1s chunks (sized off the live stage-interval floor) via runFor to drive ALL stories to concluded (chaos=100, PLEASE STAND BY) — pauses on REAL wall-clock once flash is caught mid-flight so the recording actually shows it', kind: 'chaos',
    async fn(page, ctx) {
      // Two independent gaps in the old hardcoded "12 x 25s" version, both
      // confirmed by direct repro against the live app:
      //   1. How much virtual time is actually NEEDED depends on the app's
      //      own per-story stage-interval floor (MIN_STAGE_INTERVAL_SEC) and
      //      how much elapsedSec already accumulated before this step ran
      //      (run-order dependent — e.g. run1's reversed order runs this
      //      BEFORE tier1/2/3, so elapsedSec can still be ~0 here). The old
      //      300s-total cap was sized for the app's prior 6s interval and
      //      fell short once the floor was raised to 75s (5 stages = 4
      //      advances = 300s alone, zero room for any story's offsetSec).
      //      Read the real floor live from the page rather than re-hardcode
      //      a number that can silently drift out of sync with the app.
      //   2. Even once enough virtual time IS offered, the standby flash is
      //      only visible for ~2900ms of app-time (its own auto-hide
      //      timer) before disappearing again — chunking runFor() in 25s
      //      jumps and checking only at each chunk boundary means the
      //      entire visible window can open and close INSIDE one 25s jump
      //      and never get sampled. Verified empirically: with (1) fixed
      //      alone, allConcluded reliably hit true but caught stayed false.
      //      Sampling every 1s (well under the 2900ms window) instead of
      //      every 25s fixes this; the extra evaluate() calls cost well
      //      under a second of real wall-clock time (verified ~0.8s total).
      const minInterval = await page.evaluate(() =>
        (typeof MIN_STAGE_INTERVAL_SEC === 'number' ? MIN_STAGE_INTERVAL_SEC : 0));
      const neededVirtualSec = minInterval * 4 + 60; // 4 stage advances + headroom for offsetSec/prior elapsed
      const chunks = Math.max(300, neededVirtualSec); // 1s granularity
      let caught = false;
      for (let i = 0; i < chunks && !caught; i++) {
        await page.clock.runFor(1 * 1000);
        const flashing = await page.evaluate(() => {
          const el = document.getElementById('standbyFlash');
          return !el.hidden && el.classList.contains('flashing');
        });
        if (flashing) {
          caught = true;
          // Real (non-fake-clock) pause so Playwright's video recorder
          // actually captures a frame of the flash while it's up — the
          // flash's own hide is gated on the FAKE clock's setTimeout, so a
          // real wait here does not prematurely dismiss it.
          await page.waitForTimeout(1200);
        }
      }
      // Record whether the flash was actually caught vs merely "the loop
      // ran out" — a silent miss here is exactly the coverage regression
      // this fix targets, so make it visible in the log instead of letting
      // ok:true paper over an unconcluded run.
      const allConcluded = await page.evaluate(() =>
        document.querySelectorAll('#storyGrid .story-card').length > 0 &&
        document.querySelectorAll('#storyGrid .story-card.concluded').length ===
          document.querySelectorAll('#storyGrid .story-card').length);
      ctx.standbyCaughtVisible = caught;
      ctx.allConcludedAtEnd = allConcluded;
      if (!caught) {
        console.warn(`[chaos-screenrecord] run${ctx.run}: chaosFF-concluded finished ${chunks}s virtual WITHOUT catching the standby flash (allConcluded=${allConcluded}) — coverage gap, not just a quiet miss`);
      }
    },
  });
  steps.push({
    id: 'chaosStandbyAutoHide', label: 'advance clock +3s via runFor (standby flash should auto-hide after its 2.9s timer)', kind: 'chaos',
    async fn(page) { await page.clock.runFor(3 * 1000); },
  });

  return steps;
}

function orderFor(run, steps, ctx) {
  if (run === 0) return steps; // DOM/catalog order
  if (run === 1) return [...steps].reverse();
  if (run === 2) {
    // "toggles/klaxon/chaos-drivers first" (no literal <input type=range> in
    // this app; per prior screenrecord cycle's convention, toggle-ish
    // controls + the chaos fast-forward steps stand in for "sliders").
    const weight = (s) => (s.kind === 'toggle' || s.kind === 'chaos' ? 0 : 1);
    return [...steps].sort((a, b) => weight(a) - weight(b));
  }
  if (run === 3) return seededShuffle(steps, 12345 + run * 97);
  if (run === 4) {
    const bad = new Set([...priorErrorSelectors].map((s) => s));
    // errored-first: put any step whose id/label matches a selector/label
    // that previously produced an error at the front.
    const isBad = (s) => [...bad].some((b) => b && (s.id.includes(b) || s.label.includes(b)));
    return [...steps].sort((a, b) => (isBad(b) ? 1 : 0) - (isBad(a) ? 1 : 0));
  }
  return steps;
}

async function runOne(run) {
  const recDir = path.join(REC_DIR, `chaos-run${run}`);
  fs.mkdirSync(recDir, { recursive: true });
  const browser = await chromium.launch();
  const context = await browser.newContext({
    viewport: { width: 1600, height: 900 },
    recordVideo: { dir: recDir, size: { width: 1600, height: 900 } },
  });
  const page = await context.newPage();

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

  // Install a fake clock BEFORE navigation so PANDEMONIUM-24's setInterval(tick,1000)
  // master loop, Date.now()/new Date() clock readout, and the standby-flash
  // setTimeout(...,2900) are all fast-forwardable deterministically (no real
  // multi-minute wait needed to drive 44 staggered stories to full escalation).
  await page.clock.install({ time: new Date('2026-09-23T12:00:00Z') });

  await page.goto(URL, { waitUntil: 'domcontentloaded' });
  await page.waitForTimeout(200);

  const before0 = await snap(page);
  append({ run, ts: new Date().toISOString(), selector: 'document', label: `chaos-pass run${run} initial load (file://index.html)`, action: 'goto', ok: true, effect: JSON.stringify(before0), errors: pendingErrors.splice(0) });

  const catalog = buildCatalog();
  const ctx = { run, createdIds: [], lastCreatedHeadline: null };
  const ordered = orderFor(run, catalog, ctx);

  for (const step of ordered) {
    const before = await snap(page).catch(() => ({}));
    let ok = true;
    let effect;
    try {
      await step.fn(page, ctx);
      await page.waitForTimeout(120);
      const after = await snap(page).catch(() => ({}));
      effect = effectDiff(before, after);
    } catch (err) {
      ok = false;
      effect = 'EXCEPTION: ' + String(err && err.message ? err.message : err);
    }
    const errs = pendingErrors.splice(0);
    append({ run, ts: new Date().toISOString(), selector: step.id, label: step.label, action: step.kind, ok, effect, errors: errs });
    console.log(`[run${run}] ${ok ? 'OK  ' : 'FAIL'} ${step.id.padEnd(28)} ${effect.slice(0, 140)}`);
  }

  // Final full-state snapshot for this run, useful for the report's
  // order-dependent-bug comparison across runs.
  const finalSnap = await snap(page).catch(() => ({}));
  append({ run, ts: new Date().toISOString(), selector: 'document', label: `chaos-pass run${run} FINAL state`, action: 'final-snapshot', ok: true, effect: JSON.stringify(finalSnap), errors: pendingErrors.splice(0) });

  await context.close();
  await browser.close();
  return recDir;
}

(async () => {
  const recDirs = [];
  for (let run = 0; run < 5; run++) {
    console.log(`\n=== CHAOS-PASS RUN ${run} ===`);
    const dir = await runOne(run);
    recDirs.push(dir);
  }
  console.log('\nDone. Recording dirs:', recDirs);
})().catch((e) => {
  console.error('FATAL', e);
  process.exit(1);
});