// The wall, checked where it can actually go wrong: in a browser, on a shelf long // enough that the wall is drawing a few of its rows and standing in for the rest. // // One failure is what this guards, and it is the one that has already cost a // white screen: a window written from the window before it. The wall measures // itself after every render, writes what it measured, and renders again — so an // input the measure takes off the window it just wrote feeds itself, pass after // pass, until React gives up with #185, unmounts the tree, and leaves the reader // with nothing (there is no error boundary under it). // // The trigger is not spelled out — no browser reproduces the reader's own, // which is the point — so the check drives the *class*: the wall's own box // reported at two different heights on every pass, plus one scroll to start it. // That is what any read-back is, and the wall must survive it. // // npm run build && node scripts/wall-check.mjs // PLAYWRIGHT_CORE= node scripts/wall-check.mjs // // Playwright is not a dependency of this package (a browser run needs it, a // build does not), so a run without it says SKIP and exits 0. import { spawn } from 'node:child_process'; import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); const SAMPLE_FILE = process.env.SAMPLE_FILE ?? path.resolve(FRONTEND, '../sample/_3279791-develop-check.jpg'); const APP_PORT = Number(process.env.APP_PORT ?? 5033); const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 5034); const FRAMES = Number(process.env.FRAMES ?? 300); const FOLDER = 'WallRoll'; // Every frame is the same bytes under a different name: what is being checked is // how many cards the wall will draw, not what is on them. The bytes carry no // EXIF, so a frame's date is its own `lastModified` — spread over the days below, // because a wall of one day is a wall with one header in it, and the headers are // half of what this check is for. const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`); const DAY_MS = 86400000; const PER_DAY = Number(process.env.PER_DAY ?? 10); const DAY0 = Date.UTC(2026, 0, 1); // The day a frame is filed under, off its own name — the same rule on both sides // of the wire, so the check reads the wall the way the wall was told to sort it. const dayOfName = (name) => { const n = Number((name.match(/\d+/) ?? ['1'])[0]); return DAY0 + Math.floor(Math.max(0, n - 1) / PER_DAY) * DAY_MS; }; let failures = 0; function step(name, ok, detail = '') { if (!ok) failures++; console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); return ok; } const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); process.exit(0); } // --- the roll, off a local server (the page fetches it by CORS) -------------- const jpg = await readFile(SAMPLE_FILE); const sampleServer = createServer((req, res) => { const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); if (!names.includes(name)) { res.writeHead(404).end(); return; } res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Content-Length': jpg.length, 'Access-Control-Allow-Origin': '*', }); res.end(jpg); }); await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); // `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which // on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails. // `BASE_URL` points the check at a build already being served instead of at // `dist/`, which is how the bundle on the box is checked as it stands. // `detached` so the whole group can be signalled at the end: `npx` spawns a shell // which spawns vite, and killing only the wrapper leaves vite holding the port. const app = process.env.BASE_URL ? null : spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { cwd: FRONTEND, stdio: 'ignore', detached: true, }); const stopApp = () => { if (!app?.pid) return; try { process.kill(-app.pid, 'SIGTERM'); } catch {} }; const base = process.env.BASE_URL ?? `http://127.0.0.1:${APP_PORT}`; let up = !app; for (let i = 0; i < 60 && !up; i++) { up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); if (!up) await new Promise((r) => setTimeout(r, 500)); } if (!up) { stopApp(); sampleServer.close(); console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); process.exit(1); } // --- the browser ------------------------------------------------------------ const browser = await playwright.chromium.launch({ executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader'], }); const context = await browser.newContext({ viewport: { width: 1500, height: 950 } }); // The account the shelf belongs to. `vite preview` has no backend, and the app // reads a shape off every one of these calls, so the check answers them: an // empty account is all the wall needs. await context.route('**/api/**', (route) => { const url = route.request().url(); const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check', verified: true, pro: true, admin: false } }); if (url.includes('/photos')) return json({ photos: [] }); if (url.includes('/place')) return json({ place: 'Hanoi' }); if (url.includes('/ratings')) return json({ ratings: [] }); if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } }); if (url.includes('/recipes')) return json({ recipes: [] }); return json({}); }); // Only a person can answer a folder picker; the check stands in for one. The // stand-in handles carry their methods as *non-enumerable* properties, which is // what lets them through IndexedDB's structured clone at all (a clone copies // enumerable own keys, and a function anywhere else is a DataCloneError), and // they are put back where the catalogue reads them, because a cloned handle is a // plain object with no `getFile`. await context.addInitScript( ({ origin, names, folder, days }) => { // `days` is the day of each frame, index-aligned with `names`: the init // script is cloned into the page and cannot close over the check's own scope. const dayOfNameIn = (name) => days[names.indexOf(name)] ?? Date.UTC(2026, 0, 1); const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { value: async () => new File([await (await fetch(`${origin}/${name}`)).arrayBuffer()], name, { type: 'image/jpeg', lastModified: dayOfNameIn(name), }), }); return handle; }; const dirHandle = (label, node) => { const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => [...node.files.map(fileHandle)][Symbol.iterator]() }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, getDirectoryHandle: { value: async () => { throw new Error('no such folder'); } }, getFileHandle: { value: async (name) => { if (!node.files.includes(name)) throw new Error(`no frame named ${name}`); return fileHandle(name); }, }, }); return handle; }; const root = () => dirHandle(folder, { files: names }); window.showDirectoryPicker = async () => root(); // The day each frame was given, for the header audit below: the page's own // clock reads the label, so the day is read back here the same way. window.__dayOf = (name) => dayOfNameIn(name); const revive = (row) => { if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) { row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name); } return row; }; for (const method of ['get', 'getAll']) { const original = IDBObjectStore.prototype[method]; IDBObjectStore.prototype[method] = function (...args) { const request = original.apply(this, args); const descriptor = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); Object.defineProperty(request, 'result', { configurable: true, get() { const value = descriptor.get.call(this); return Array.isArray(value) ? value.map(revive) : revive(value); }, }); return request; }; } }, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names, folder: FOLDER, days: names.map(dayOfName) } ); const page = await context.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e.message).slice(0, 200))); const read = () => page.evaluate(() => { const el = document.querySelector('.adm-grid'); return { cards: el ? el.querySelectorAll('.adm-card').length : 0, wall: el ? el.scrollHeight > el.clientHeight : false, // The white screen itself: no rail, no words, nothing drawn. blank: document.body.innerText.trim().length < 40 && !document.querySelector('nav.rail'), }; }); await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); await page.waitForSelector(`[data-key="lib-node-${FOLDER}"]`, { timeout: 60_000 }); await page .waitForFunction( (n) => (document.querySelector('[data-key="lib-count"]')?.textContent ?? '').includes(String(n)), FRAMES, { timeout: 240_000, polling: 250 } ) .catch(() => {}); const counted = await page.$eval('[data-key="lib-count"]', (e) => e.textContent?.trim() ?? ''); step('the roll is read whole', counted.includes(String(FRAMES)), counted); // The day the header of a card stands under, read off the sections themselves: // every header must name the day of every card filed under it. A day whose first // rows are above the window still gets its header where the eye lands (see // `dayRuns`), so the rule is "a header names its own cards" and not "one header // per day in the shelf". const dayAudit = () => page.evaluate(() => { const key = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; const out = { sections: 0, cards: 0, labelled: 0, bad: [] }; for (const section of document.querySelectorAll('.adm-day')) { out.sections++; const own = (section.getAttribute('data-key') ?? '').replace('lib-day-', ''); const head = section.querySelector('.adm-day-head')?.textContent?.trim() ?? ''; if (head) out.labelled++; const names = [...section.querySelectorAll('[data-key^="lib-photo-"]')].map( (el) => (el.getAttribute('data-key') ?? '').split('/').pop() ); const days = new Set(names.map((name) => key(window.__dayOf(name)))); if (!names.length || days.size !== 1 || !days.has(own)) out.bad.push({ own, head, days: [...days], names: names.slice(0, 2) }); } out.cards = document.querySelectorAll('.adm-card').length; return out; }); await page.click('[data-key="lib-view-grid"]'); await page.waitForSelector('.adm-grid', { timeout: 15_000 }); await page.waitForFunction(() => document.querySelectorAll('.adm-card').length > 0, null, { timeout: 30_000 }); await page.waitForTimeout(2000); const wall = await read(); step('the wall draws a screenful of the shelf', wall.cards > 0 && wall.cards < FRAMES, `${wall.cards} of ${FRAMES} cards drawn`); step('and stands in for the rows it is not drawing', wall.wall, 'the shelf scrolls'); const days1 = await dayAudit(); step('the wall is cut into the days its frames were shot', days1.sections > 1, `${days1.sections} headers over ${days1.cards} cards`); step('every header names the day of the cards under it', days1.bad.length === 0, JSON.stringify(days1.bad.slice(0, 2))); step('and every header carries its day in words', days1.labelled === days1.sections, `${days1.labelled} of ${days1.sections}`); // The same wall, scrolled to where no day starts at the fold: the headers have to // move with it, which is the whole risk of cutting a virtualised wall into days. await page.evaluate(() => { const el = document.querySelector('.adm-grid'); el.scrollTop = Math.round(el.scrollHeight * 0.7); el.dispatchEvent(new Event('scroll')); }); await page.waitForTimeout(1500); const days2 = await dayAudit(); step('a scrolled wall still files every card under its own day', days2.sections > 0 && days2.bad.length === 0, JSON.stringify(days2.bad.slice(0, 2))); // The class of trigger, made to happen: one input the wall reads back off itself, // moving on every pass. await page.evaluate(() => { const el = document.querySelector('.adm-grid'); const h = el.clientHeight; let n = 0; Object.defineProperty(el, 'clientHeight', { configurable: true, get: () => (n++ % 2 ? h : h + 300) }); el.dispatchEvent(new Event('scroll')); }); await page.waitForTimeout(8000); const after = await read(); const looped = errors.filter((e) => e.includes('185') || e.includes('update depth')); step( 'a wall fed its own window does not take the screen down', !after.blank && after.cards > 0 && looped.length === 0, `blank=${after.blank}, cards=${after.cards}, errors=${looped.length ? looped[0] : 'none'}` ); step('the wall is still the wall', after.cards > 0, `${after.cards} cards after the feed`); await page.screenshot({ path: process.env.SHOT ?? '/tmp/wall-check.png' }).catch(() => {}); if (errors.length) console.log('page errors:', errors.slice(0, 6).join(' // ')); await browser.close(); stopApp(); sampleServer.close(); console.log(failures ? `\n${failures} FAILED` : '\nall ok'); process.exit(failures ? 1 : 0);