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