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