← back to Paul Conrad Cartoons Rebrand
scripts/verify-inkwell.cjs
114 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 node scripts/verify-inkwell.cjs
// 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 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/cartoons', '/health', '/', '/app.js', '/p24.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 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');
// Name-free page text (whole document, incl. research section + bio modal)
const text = await page.evaluate(() => document.documentElement.innerText + ' ' + document.title);
check('page text contains no name', !NAME.test(text));
await page.click('#section-links button[data-section="bio"]');
const bioText = await page.$eval('#modal-body', el => el.innerText);
check('bio modal contains no name', !NAME.test(bioText), bioText.split('\n')[1]);
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); });