From 549bfab310da17cdf30b9c3aaa80d0b216a42f28 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sat, 10 Oct 2026 07:53:38 +0700 Subject: [PATCH] test(library): check that the wall survives being fed its own window --- docker/frontend/scripts/wall-check.mjs | 245 +++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docker/frontend/scripts/wall-check.mjs diff --git a/docker/frontend/scripts/wall-check.mjs b/docker/frontend/scripts/wall-check.mjs new file mode 100644 index 0000000..7e420fc --- /dev/null +++ b/docker/frontend/scripts/wall-check.mjs @@ -0,0 +1,245 @@ +// 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. +const names = Array.from({ length: FRAMES }, (_, i) => `frame-${String(i + 1).padStart(4, '0')}.jpg`); + +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 }) => { + 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: Date.UTC(2026, 0, 1), + }), + }); + 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(); + + 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 } +); + +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); + +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'); + +// 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);