← back to Paul Conrad Cartoons Shadowman
scripts/verify-inkwell.cjs
289 lines
#!/usr/bin/env node
// verify-inkwell.cjs — E2E check of the Inkwell app (TK-12230). $0, local.
// Usage: BASE=http://localhost:9947 SHOTS=/path/to/dir [INKWELL_ROOT=/path/to/app/copy] node scripts/verify-inkwell.cjs
// The curation checks (TK-12241) MUTATE the target's data (then undo it). Run them against a scratch
// copy of the app (INKWELL_ROOT = that copy, enables the file-move / idea-queue assertions), never
// against the live pm2 checkout.
// Exit 0 only if every assertion passes. Includes a negative self-test proving the name
// detector goes red on an injected fault (a check that cannot fail proves nothing).
const path = require('path');
const fs = require('fs');
const { chromium } = require(process.env.PLAYWRIGHT_PATH || path.join(process.env.HOME, 'Projects/jevrun-runner/node_modules/playwright'));
const BASE = process.env.BASE || 'http://localhost:9947';
const ROOT = process.env.INKWELL_ROOT || null;
const SHOTS = process.env.SHOTS || path.join(__dirname, '..', 'shots');
const NAME = new RegExp(['con', 'rad'].join(''), 'i');
const results = [];
const check = (name, ok, detail = '') => { results.push({ name, ok: !!ok, detail }); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); };
(async () => {
fs.mkdirSync(SHOTS, { recursive: true });
// Negative self-test: the detector must flag an injected occurrence.
check('negative self-test: name detector flags injected text', NAME.test('cartoon by P. ' + 'Con' + 'rad') && !NAME.test('Inkwell archive'));
const p24 = await (await fetch(BASE + '/api/p24')).json();
const expected = p24.items.length;
const expectedImgs = p24.items.reduce((n, i) => n + 1 + (i.panels || []).length, 0);
check('/api/p24 has items', expected > 0, `${p24.counts.photo} photos + ${p24.counts.cartoon} cartoons = ${expected}; ${expectedImgs} images incl. panels`);
for (const ep of ['/api/p24', '/api/pd-cartoons', '/api/shadowman', '/health', '/', '/app.js', '/p24.js', '/shadowman.js', '/style.css']) {
const r = await fetch(BASE + ep);
const body = await r.text();
check(`${ep} 200 and name-free`, r.status === 200 && !NAME.test(body), `status ${r.status}`);
}
const gone = await fetch(BASE + '/api/cartoons');
check('research-records endpoint removed (/api/cartoons 404)', gone.status === 404, `status ${gone.status}`);
const pd = await (await fetch(BASE + '/api/pd-cartoons')).json();
check('/api/pd-cartoons has items, each with rights + local image + http source', pd.items.length > 0 && pd.items.every(i => i.rights && /^\/pd\//.test(i.image) && /^https?:/.test(i.source_url)), `${pd.items.length} items`);
// Shadow Man (TK-12241): API shape + every kept piece has title/caption/theme/era/ISO date + local jpg.
const sm = await (await fetch(BASE + '/api/shadowman')).json();
check('/api/shadowman has items with title/caption/theme/era/created_at/local src', sm.items.length > 0 && sm.items.every(i => i.title && i.caption && i.theme && i.era && !isNaN(Date.parse(i.created_at)) && /^\/shadowman\/[\w-]+\.jpg$/.test(i.src) && i.signature === 'Shadow Man'), `${sm.items.length} items (rendered ${sm.counts.rendered}, kept ${sm.counts.kept}, dropped ${sm.counts.dropped})`);
check('/api/shadowman counts consistent', sm.counts.kept === sm.items.length && sm.counts.kept + sm.counts.dropped === sm.counts.rendered);
const smTheme = sm.items[0].theme;
const smFiltered = await (await fetch(BASE + '/api/shadowman?theme=' + encodeURIComponent(smTheme))).json();
check('/api/shadowman?theme= filters server-side', smFiltered.items.length === sm.items.filter(i => i.theme === smTheme).length && smFiltered.items.every(i => i.theme === smTheme), `${smTheme}: ${smFiltered.items.length}`);
const browser = await chromium.launch();
const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 } });
const page = await ctx.newPage();
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', e => errors.push(String(e)));
const resp = await page.goto(BASE + '/', { waitUntil: 'networkidle' });
check('page loads 200', resp.status() === 200);
await page.waitForSelector('.p24-card');
// Force every lazy image to load, then assert each rendered.
await page.evaluate(() => document.querySelectorAll('#p24-grid img').forEach(i => { i.loading = 'eager'; }));
for (let y = 0; y < 60; y++) { await page.mouse.wheel(0, 1500); await page.waitForTimeout(60); }
await page.waitForFunction(() => [...document.querySelectorAll('#p24-grid img')].every(i => i.complete), null, { timeout: 30000 });
const imgs = await page.evaluate(() => [...document.querySelectorAll('#p24-grid img')].map(i => ({ src: i.getAttribute('src'), w: i.naturalWidth })));
const broken = imgs.filter(i => !(i.w > 0));
check('every gallery image rendered (naturalWidth>0)', imgs.length === expectedImgs && broken.length === 0, `${imgs.length}/${expectedImgs} rendered, broken=${broken.length}${broken.length ? ' ' + broken.slice(0, 3).map(b => b.src).join(',') : ''}`);
let bad = 0;
for (const i of imgs) { const r = await fetch(BASE + i.src); if (r.status !== 200) bad++; }
check('every gallery image HTTP 200', bad === 0, `${imgs.length - bad}/${imgs.length}`);
const cardCount = await page.locator('.p24-card').count();
check('card count = expected total', cardCount === expected, `${cardCount}/${expected}`);
const chips = await page.evaluate(() => [...document.querySelectorAll('.p24-card .when')].map(w => ({ t: w.textContent, iso: w.title })));
check('every card has date+time chip with ISO title', chips.length === expected && chips.every(c => /\d{4}/.test(c.t) && /:\d{2}/.test(c.t) && !isNaN(Date.parse(c.iso))), chips[0] && chips[0].t);
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: path.join(SHOTS, 'inkwell-gallery-all.png') });
await page.screenshot({ path: path.join(SHOTS, 'inkwell-full.png'), fullPage: true });
// Filter
await page.click('#p24-filter button[data-type="cartoon"]');
const nCart = await page.locator('.p24-card').count();
check('filter Cartoons', nCart === p24.counts.cartoon && await page.locator('.p24-card.photo').count() === 0, `${nCart}`);
await page.screenshot({ path: path.join(SHOTS, 'inkwell-cartoons.png') });
await page.click('#p24-filter button[data-type="photo"]');
const nPhoto = await page.locator('.p24-card').count();
check('filter Photos', nPhoto === p24.counts.photo, `${nPhoto}`);
// Sort
await page.selectOption('#p24-sort', 'title-az');
const titles = await page.$$eval('.p24-card .title', els => els.map(e => e.textContent));
check('sort Title A→Z', titles.join('|') === [...titles].sort((a, b) => a.localeCompare(b)).join('|'), titles.slice(0, 2).join(' / '));
// Density
await page.$eval('#p24-density', el => { el.value = '160'; el.dispatchEvent(new Event('input', { bubbles: true })); });
const cols160 = await page.$eval('#p24-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
await page.screenshot({ path: path.join(SHOTS, 'inkwell-photos-dense.png') });
// Persistence across reload
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('.p24-card');
const persisted = await page.evaluate(() => ({
type: document.querySelector('#p24-filter button.active')?.dataset.type,
sort: document.getElementById('p24-sort').value,
density: document.getElementById('p24-density').value,
cols: getComputedStyle(document.getElementById('p24-grid')).gridTemplateColumns.split(' ').length,
cards: document.querySelectorAll('.p24-card').length,
}));
check('filter/sort/density persist across reload', persisted.type === 'photo' && persisted.sort === 'title-az' && persisted.density === '160' && persisted.cols === cols160 && persisted.cards === p24.counts.photo, JSON.stringify(persisted));
await page.$eval('#p24-density', el => { el.value = '400'; el.dispatchEvent(new Event('input', { bubbles: true })); });
const cols400 = await page.$eval('#p24-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
check('density slider changes column count', cols160 > cols400, `${cols160} cols @160 -> ${cols400} cols @400`);
// Modal (a cartoon with panels)
await page.click('#p24-filter button[data-type="all"]');
await page.locator('.p24-card.cartoon').filter({ has: page.locator('.panels') }).first().click();
await page.waitForSelector('.modal .p24-full');
await page.waitForTimeout(500);
await page.screenshot({ path: path.join(SHOTS, 'inkwell-modal.png') });
await page.keyboard.press('Escape');
// ---- Shadow Man gallery (TK-12241) ----
await page.evaluate(() => Object.keys(localStorage).filter(k => k.startsWith('inkwell:sm:')).forEach(k => localStorage.removeItem(k)));
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('.sm-card');
await page.evaluate(() => document.querySelectorAll('#sm-grid img').forEach(i => { i.loading = 'eager'; }));
await page.locator('#shadowman').scrollIntoViewIfNeeded();
await page.waitForFunction(() => [...document.querySelectorAll('#sm-grid img')].every(i => i.complete), null, { timeout: 60000 });
const smImgs = await page.evaluate(() => [...document.querySelectorAll('#sm-grid img')].map(i => ({ src: i.getAttribute('src'), w: i.naturalWidth })));
check('Shadow Man: card count = /api/shadowman items', smImgs.length === sm.items.length, `${smImgs.length}/${sm.items.length}`);
check('Shadow Man: every image rendered (naturalWidth>0)', smImgs.length > 0 && smImgs.every(i => i.w > 0), `${smImgs.filter(i => i.w > 0).length}/${smImgs.length}`);
let smBad = 0;
for (const i of smImgs) { const r = await fetch(BASE + i.src); if (r.status !== 200 || !/image\/jpeg/.test(r.headers.get('content-type') || '')) smBad++; }
check('Shadow Man: every image HTTP 200 image/jpeg', smBad === 0, `${smImgs.length - smBad}/${smImgs.length}`);
const smChips = await page.evaluate(() => [...document.querySelectorAll('.sm-card .when')].map(w => ({ t: w.textContent, iso: w.title })));
check('Shadow Man: every card has date+time chip with ISO title', smChips.length === sm.items.length && smChips.every(c => /\d{4}/.test(c.t) && /:\d{2}/.test(c.t) && !isNaN(Date.parse(c.iso))), smChips[0] && smChips[0].t);
check('Shadow Man: every card shows theme + era chips', await page.locator('.sm-card .sm-theme').count() === sm.items.length && await page.locator('.sm-card .sm-era').count() === sm.items.length);
await page.locator('#shadowman').screenshot({ path: path.join(SHOTS, 'inkwell-shadowman.png') });
await page.selectOption('#sm-theme', smTheme);
check('Shadow Man: theme filter', await page.locator('.sm-card').count() === sm.items.filter(i => i.theme === smTheme).length, smTheme);
const smEra = sm.items[sm.items.length - 1].era;
await page.selectOption('#sm-theme', 'all');
await page.selectOption('#sm-era', smEra);
check('Shadow Man: era filter', await page.locator('.sm-card').count() === sm.items.filter(i => i.era === smEra).length, smEra);
await page.selectOption('#sm-sort', 'title-az');
const smTitles = await page.$$eval('.sm-card .title', els => els.map(e => e.textContent));
check('Shadow Man: sort Title A→Z', smTitles.join('|') === [...smTitles].sort((a, b) => a.localeCompare(b)).join('|'));
await page.$eval('#sm-density', el => { el.value = '180'; el.dispatchEvent(new Event('input', { bubbles: true })); });
const smCols180 = await page.$eval('#sm-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('.sm-card');
const smPersist = await page.evaluate(() => ({ era: document.getElementById('sm-era').value, sort: document.getElementById('sm-sort').value, density: document.getElementById('sm-density').value, cols: getComputedStyle(document.getElementById('sm-grid')).gridTemplateColumns.split(' ').length }));
check('Shadow Man: era/sort/density persist across reload', smPersist.era === smEra && smPersist.sort === 'title-az' && smPersist.density === '180' && smPersist.cols === smCols180, JSON.stringify(smPersist));
await page.$eval('#sm-density', el => { el.value = '460'; el.dispatchEvent(new Event('input', { bubbles: true })); });
const smCols460 = await page.$eval('#sm-grid', g => getComputedStyle(g).gridTemplateColumns.split(' ').length);
check('Shadow Man: density slider changes column count', smCols180 > smCols460, `${smCols180} @180 -> ${smCols460} @460`);
await page.selectOption('#sm-era', 'all');
await page.locator('.sm-card').first().click();
await page.waitForSelector('.modal .p24-full');
const smModal = await page.$eval('#modal-body', el => el.innerText);
check('Shadow Man: modal shows signature + caption', /Shadow Man/.test(smModal) && sm.items.some(i => smModal.includes(i.caption)));
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(SHOTS, 'inkwell-shadowman-modal.png') });
await page.keyboard.press('Escape');
// ---- Curation (TK-12241): select 1..many, big Approve / Delete, bulk bar, confirm modal ----
let nativeDialogs = 0;
page.on('dialog', d => { nativeDialogs++; d.dismiss(); });
await page.evaluate(() => Object.keys(localStorage).filter(k => k.startsWith('inkwell:')).forEach(k => localStorage.removeItem(k)));
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#sm-grid .sm-card');
const btnH = await page.$$eval('#sm-grid .big-btn, #ar-grid .big-btn', els => els.map(e => e.getBoundingClientRect().height));
check('curation: every Approve/Delete button >= 44px tall', btnH.length > 0 && btnH.every(h => h >= 44), `${btnH.length} buttons, min ${Math.min(...btnH).toFixed(0)}px`);
const selH = await page.$$eval('#sm-grid .sel', els => els.map(e => e.getBoundingClientRect().height));
check('curation: select targets >= 44px tall', selH.every(h => h >= 44), `min ${Math.min(...selH).toFixed(0)}px`);
check('curation: bulk bar hidden with nothing selected', await page.locator('#sm-bulk').isHidden());
const smIds = await page.$$eval('#sm-grid .sm-card', els => els.map(e => e.dataset.id));
const [a1, a2, d1] = smIds;
// keyboard: Space on a focused checkbox selects it
await page.focus(`#sm-grid .sel-box[data-id="${a1}"]`);
await page.keyboard.press('Space');
await page.check(`#sm-grid .sel-box[data-id="${a2}"]`);
check('curation: keyboard + click select 2 -> bulk bar "Approve 2"', await page.locator('#sm-bulk').isVisible() && (await page.textContent('#sm-bulk [data-bulk="approve"]')).trim() === 'Approve 2');
await page.screenshot({ path: path.join(SHOTS, 'inkwell-curate-selected.png') });
await page.click('#sm-bulk [data-bulk="approve"]');
await page.waitForFunction(ids => ids.every(id => document.querySelector(`.sm-card[data-id="${id}"] .status-chip.status-approved`)), [a1, a2]);
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#sm-grid .sm-card');
const approvedAfter = await page.$$eval('#sm-grid .sm-card.status-approved', els => els.map(e => e.dataset.id).sort());
const apiApproved = (await (await fetch(BASE + '/api/shadowman?status=approved')).json()).items.map(i => i.id).sort();
check('curation: Approve 2 persisted across reload (UI + API)', JSON.stringify(approvedAfter) === JSON.stringify([a1, a2].sort()) && JSON.stringify(apiApproved) === JSON.stringify([a1, a2].sort()), approvedAfter.join(','));
// delete 1 via its big button -> in-page confirm modal
const d1Src = (await (await fetch(BASE + '/api/shadowman')).json()).items.find(i => i.id === d1).src;
await page.click(`.sm-card[data-id="${d1}"] .big-btn.delete`);
check('curation: delete opens the in-page confirm modal', await page.locator('#confirm-backdrop.open').isVisible());
await page.screenshot({ path: path.join(SHOTS, 'inkwell-curate-confirm.png') });
await page.click('#confirm-ok');
await page.waitForFunction(id => !document.querySelector(`.sm-card[data-id="${id}"]`), d1);
const trashed = await fetch(BASE + d1Src);
let moved = true;
if (ROOT) moved = !fs.existsSync(path.join(ROOT, 'public', d1Src)) && fs.existsSync(path.join(ROOT, 'generator', 'out', '_trash', path.basename(d1Src)));
check('curation: Delete hides card, image moved to generator/out/_trash (HTTP 404 on old src)', trashed.status === 404 && moved, `status ${trashed.status}${ROOT ? ', trash file present' : ' (fs check skipped: no INKWELL_ROOT)'}`);
await page.check('#sm-show-deleted');
await page.waitForSelector(`.sm-card[data-id="${d1}"] .big-btn.restore`);
await page.click(`.sm-card[data-id="${d1}"] .big-btn.restore`);
await page.waitForSelector(`.sm-card[data-id="${d1}"] .status-chip.status-pending`);
const back = await fetch(BASE + d1Src);
check('curation: Restore brings the image back (HTTP 200) and status pending', back.status === 200, `status ${back.status}`);
await page.uncheck('#sm-show-deleted');
// bulk Select all / Clear
await page.check(`#sm-grid .sel-box[data-id="${a1}"]`);
await page.click('#sm-bulk [data-bulk="all"]');
const nAll = await page.locator('#sm-grid .sel-box:checked').count();
await page.click('#sm-bulk [data-bulk="clear"]');
check('curation: Select all / Clear', nAll === smIds.length && await page.locator('#sm-grid .sel-box:checked').count() === 0 && await page.locator('#sm-bulk').isHidden(), `${nAll}/${smIds.length}`);
// undo the approvals so the scratch data ends where it started
await fetch(BASE + '/api/shadowman/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [a1, a2] }) });
// Arena ideas
const ar = await (await fetch(BASE + '/api/arena-ideas')).json();
check('/api/arena-ideas has items with model/theme/title/caption/scene + ISO date, name-free', ar.items.length > 0 && ar.items.every(i => i.model && i.theme && i.title && i.caption && i.scene && !isNaN(Date.parse(i.created_at))) && !NAME.test(JSON.stringify(ar)), `${ar.items.length} ideas from ${Object.keys(ar.per_model).length} models`);
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#ar-grid .ar-card');
check('arena: card count = /api/arena-ideas items', await page.locator('#ar-grid .ar-card').count() === ar.items.length);
const arChips = await page.$$eval('#ar-grid .ar-card .when', els => els.map(w => ({ t: w.textContent, iso: w.title })));
check('arena: every card has date+time chip with ISO title', arChips.length === ar.items.length && arChips.every(c => /:\d{2}/.test(c.t) && !isNaN(Date.parse(c.iso))));
const firstModel = ar.items[0].model;
await page.selectOption('#ar-model', firstModel);
check('arena: model filter', await page.locator('#ar-grid .ar-card').count() === ar.items.filter(i => i.model === firstModel).length, firstModel);
await page.selectOption('#ar-model', 'all');
const arIds = await page.$$eval('#ar-grid .ar-card', els => els.map(e => e.dataset.id));
const [q1, q2] = arIds;
await page.check(`#ar-grid .sel-box[data-id="${q1}"]`);
await page.check(`#ar-grid .sel-box[data-id="${q2}"]`);
await page.click('#ar-bulk [data-bulk="approve"]');
await page.waitForFunction(ids => ids.every(id => document.querySelector(`.ar-card[data-id="${id}"] .status-chip.status-approved`)), [q1, q2]);
await page.reload({ waitUntil: 'networkidle' });
await page.waitForSelector('#ar-grid .ar-card');
const arApproved = await page.$$eval('#ar-grid .ar-card.status-approved', els => els.map(e => e.dataset.id).sort());
let queued = null;
if (ROOT) queued = JSON.parse(fs.readFileSync(path.join(ROOT, 'generator', 'idea-queue.json'), 'utf8')).items.map(b => b.id).sort();
check('arena: Approve 2 persisted across reload + queued as briefs in generator/idea-queue.json', JSON.stringify(arApproved) === JSON.stringify([q1, q2].sort()) && (!ROOT || JSON.stringify(queued) === JSON.stringify([q1, q2].sort())), ROOT ? `queue=${queued.length}` : 'queue check skipped');
await page.click(`.ar-card[data-id="${q1}"] .big-btn.delete`);
await page.click('#confirm-ok');
await page.waitForFunction(id => !document.querySelector(`.ar-card[data-id="${id}"]`), q1);
const arDoc = await (await fetch(BASE + '/api/arena-ideas')).json();
if (ROOT) queued = JSON.parse(fs.readFileSync(path.join(ROOT, 'generator', 'idea-queue.json'), 'utf8')).items.map(b => b.id);
check('arena: Delete keeps the idea in the file (status deleted), hides it, and pulls it from the queue', arDoc.items.find(i => i.id === q1).status === 'deleted' && (!ROOT || !queued.includes(q1)));
await page.check('#ar-show-deleted');
await page.click(`.ar-card[data-id="${q1}"] .big-btn.restore`);
await page.waitForSelector(`.ar-card[data-id="${q1}"] .status-chip.status-pending`);
check('arena: Restore returns a deleted idea to pending', (await (await fetch(BASE + '/api/arena-ideas')).json()).items.find(i => i.id === q1).status === 'pending');
await page.uncheck('#ar-show-deleted');
await page.locator('#arena').screenshot({ path: path.join(SHOTS, 'inkwell-arena.png') });
await fetch(BASE + '/api/arena-ideas/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [q2] }) });
check('curation: no native window.confirm/alert dialogs used', nativeDialogs === 0, `${nativeDialogs}`);
const lan = await fetch(BASE + '/api/shadowman/approve', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
check('curation: POST without ids rejected (400)', lan.status === 400, `status ${lan.status}`);
// Name-free page text (whole document)
const text = await page.evaluate(() => document.documentElement.innerText + ' ' + document.title);
check('page text contains no name', !NAME.test(text));
// Public-domain gallery (replaced the research-records section)
check('research-records section gone', !/Research records|image not displayed/i.test(text));
await page.waitForSelector('#grid .card');
await page.evaluate(() => document.querySelectorAll('#grid img').forEach(i => { i.loading = 'eager'; }));
await page.waitForFunction(() => [...document.querySelectorAll('#grid img')].every(i => i.complete), null, { timeout: 60000 });
const pdImgs = await page.evaluate(() => [...document.querySelectorAll('#grid img')].map(i => i.naturalWidth));
check('every public-domain image rendered', pdImgs.length === pd.items.length && pdImgs.every(w => w > 0), `${pdImgs.filter(w => w > 0).length}/${pd.items.length}`);
const nMuseum = pd.items.filter(i => i.source_key !== 'dpla').length;
await page.click('#section-links button[data-kind="museum"]');
check('Museums nav filter', await page.locator('#grid .card').count() === nMuseum, `${nMuseum}`);
await page.click('#section-links button[data-kind="library"]');
check('Libraries & universities nav filter', await page.locator('#grid .card').count() === pd.items.length - nMuseum, `${pd.items.length - nMuseum}`);
await page.locator('#pd').screenshot({ path: path.join(SHOTS, 'inkwell-pd-libraries.png') });
await page.click('#section-links button[data-kind="all"]');
await page.locator('#grid .card').first().click();
await page.waitForSelector('.modal .modal-img');
const modalText = await page.$eval('#modal-body', el => el.innerText);
check('PD modal shows rights + source record', /Rights/.test(modalText) && /(Public domain|No Copyright)/.test(modalText) && /Source record/.test(modalText));
await page.waitForTimeout(400);
await page.screenshot({ path: path.join(SHOTS, 'inkwell-pd-modal.png') });
await page.keyboard.press('Escape');
await page.locator('#pd').scrollIntoViewIfNeeded();
await page.screenshot({ path: path.join(SHOTS, 'inkwell-pd.png') });
check('0 console errors', errors.length === 0, errors.slice(0, 3).join(' | '));
// Reset persisted prefs so the running app opens on defaults
await page.evaluate(() => Object.keys(localStorage).filter(k => k.startsWith('inkwell:')).forEach(k => localStorage.removeItem(k)));
await browser.close();
const failed = results.filter(r => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} checks passed`);
process.exit(failed.length ? 1 : 0);
})().catch(e => { console.error('FAIL verifier crashed:', e); process.exit(2); });