// 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; // Midday, so the day a frame is filed under on the reader's own clock is the day // the check counts, whatever the box is set to. const DAY0 = Date.UTC(2023, 0, 12, 12); // The days the roll was shot on, and they are not equal: a roll is an afternoon // of three frames and a wedding of four hundred, and the wall's own model of its // rows is the thing that has to survive that. A shelf of equal days is a shelf a // flat count is right about — `count / cols` rows of `cols` cards, with the step // between them read off the run in hand standing for the rest — and a check of // equal days says nothing about the shelf that is not. And the order matters as // much as the sizes: the head of the roll is what a step read off the drawn rows // comes from, so the roll is sixty days of two under four days of forty — and the // head of it is a screen of bands with a frame or two under each, which is what // puts a step taken as one average, over a band's row and a row of frames both, // furthest out of true. (One small day among runs of a hundred and twenty was // tried and does not show it; a first cut of this check missed the shape and had // to be walked straight instead of jumped.) const SMALL_DAY = 2; const BIG_DAY = 40; const DAY_PATTERN = [...Array(60).fill(SMALL_DAY), ...Array(4).fill(BIG_DAY)]; // 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 dayIndex = (n) => { let left = Math.max(0, n); for (let cycle = 0; cycle < 1000; cycle++) { for (let i = 0; i < DAY_PATTERN.length; i++) { if (left < DAY_PATTERN[i]) return cycle * DAY_PATTERN.length + i; left -= DAY_PATTERN[i]; } } return 0; }; // The shooting days are laid out on the calendar rather than end to end: what the // ruler beside the wall is read for is the months and the years of the roll, and // a roll shot on consecutive days is a roll of one month and one tick. Eleven // days between one shooting day and the next leaves it two years long with whole // months nothing was shot in standing between them — and a month with no frame is // a month with no tick, which is the ruler's own claim. const DAY_STEP = 11; const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * DAY_STEP * DAY_MS; const yearOf = (ts) => String(new Date(ts).getFullYear()); const monthOf = (ts) => { const d = new Date(ts); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; }; const rollDays = names.map(dayOfName); const rollYears = [...new Set(rollDays.map(yearOf))]; // The months the roll was shot in, in the shelf's own order: the ruler is a tick // a month of the roll, and the months between them are the ones it skips. const rollMonths = [...new Set(rollDays.map(monthOf))]; 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))); // The day a frame of this roll was shot on, on the reader's own clock — the same // cut the wall files it under. const dayKeyOf = (name) => { const d = new Date(dayOfName(name)); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; // What the wall is drawing, read off the boxes: every row it has drawn, the frames // in them, and the pane and body they are drawn in. The whole check is read off // this, so a row standing at a top the layout did not give it is a row this sees. const audit = () => page.evaluate(() => { const el = document.querySelector('.adm-grid'); if (!el) return null; const box = el.getBoundingClientRect(); const body = el.querySelector('.adm-wall-body'); const rows = [...el.querySelectorAll('.adm-row')].map((row) => { const rb = row.getBoundingClientRect(); const month = row.querySelector('.adm-month-head'); return { band: row.classList.contains('adm-band'), day: row.getAttribute('data-day') ?? '', label: row.querySelector('.adm-day-head')?.textContent?.trim() ?? '', month: month ? (month.getAttribute('data-key') ?? '').replace('lib-month-', '') : '', top: Math.round(rb.top - box.top), bottom: Math.round(rb.bottom - box.top), tiles: [...row.querySelectorAll('.lib-tile')].map((t) => { const tb = t.getBoundingClientRect(); const img = t.querySelector('img'); return { name: (t.getAttribute('data-key') ?? '').split('/').pop(), // Kept as the browser laid them out: a row that is justified to the wall // is a sum of widths, and rounding each of them is a sum off by up to // half a frame a piece. w: Number(tb.width.toFixed(2)), h: Number(tb.height.toFixed(2)), ar: img && img.naturalWidth > 0 ? Number((img.naturalWidth / img.naturalHeight).toFixed(3)) : null, }; }), }; }); const run = document.querySelector('.adm-row:not(.adm-band)'); return { scrollTop: Math.round(el.scrollTop), clientH: Math.round(el.clientHeight), clientW: Math.round(el.clientWidth), scrollH: el.scrollHeight, bodyH: body ? Math.round(body.getBoundingClientRect().height) : null, gap: run ? Number.parseFloat(getComputedStyle(run).columnGap) || 0 : 0, rows, }; }); // A stop on the shelf: a fraction of the room under it, or its own end at -1. const scrollTo = (f) => page.evaluate((at) => { const el = document.querySelector('.adm-grid'); el.scrollTop = at < 0 ? el.scrollHeight : Math.round((el.scrollHeight - el.clientHeight) * at); el.dispatchEvent(new Event('scroll')); }, f); const read = () => page.evaluate(() => { const el = document.querySelector('.adm-grid'); return { tiles: el ? el.querySelectorAll('.lib-tile').length : 0, // 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-row', { timeout: 15_000 }); await page.waitForFunction(() => document.querySelectorAll('.lib-tile').length > 0, null, { timeout: 30_000 }); await page.waitForTimeout(2500); const opened = await audit(); const openedTiles = opened.rows.reduce((n, r) => n + r.tiles.length, 0); step('the wall draws a screenful of the shelf', openedTiles > 0 && openedTiles < FRAMES, `${openedTiles} of ${FRAMES} frames drawn`); step('and stands in for the rows it is not drawing', opened.scrollH > opened.clientH, `a shelf of ${opened.scrollH}px in a pane of ${opened.clientH}px`); // The shelf's own height, which is the layout's and not the DOM's: a body standing // shorter than the layout puts the frames under the fold out of reach, and one // standing taller leaves the reader at the end of the scroll with a screen of // nothing under the last frame drawn. step( 'the body of the wall stands the shelf the layout counted', opened.bodyH !== null && Math.abs(opened.scrollH - opened.bodyH) <= 1, `body ${opened.bodyH}px, scroll ${opened.scrollH}px` ); // The two frames the pane is drawn for, and the ones a shelf that moves under the // reader hides: the row over the top edge of the pane and the row under its bottom // edge are rows the wall has to have drawn, at every stop of the roll, its two // ends included. Read down the shelf and not at one stop, since a stop of any one // fraction lands inside a day often as it lands on a fold. const stops = [0, 0.15, 0.35, 0.55, 0.75, 0.95, -1]; // Read down the drawn rows, from the top edge of the pane to its bottom one, and // the holes between them: two rows of the wall stand a gap apart, and a gap is // not a hole — anything wider than the gap is a row the window left out. const holes = []; const SLIT = 4; const unjustified = []; const dayBad = []; const seenDays = new Set(); for (const at of stops) { await scrollTo(at); await page.waitForTimeout(900); const a = await audit(); let cursor = 0; for (const r of [...a.rows].sort((x, y) => x.top - y.top)) { if (r.bottom <= 0) continue; if (r.top > cursor + SLIT) holes.push({ at, slit: [cursor, r.top] }); if (r.bottom > cursor) cursor = r.bottom; } if (cursor < a.clientH - 1) holes.push({ at, open: [cursor, a.clientH] }); // A row of frames is justified to the wall: the frames of a day packed into a row // until it is at least as wide as the wall, and the row then stretched to exactly // that width, so every frame keeps its own proportions and meets its neighbours. // Every row of a day but the last is that width; the last one is as wide as the // day's frames come out, which is what tells one day from the next. const run = a.rows.filter((r) => !r.band); for (let i = 0; i < run.length; i++) { const row = run[i]; if (!row.tiles.length) continue; const sum = row.tiles.reduce((n, t) => n + t.w, 0) + a.gap * (row.tiles.length - 1); const last = i + 1 >= run.length || run[i + 1].day !== row.day; if (Math.abs(sum - a.clientW) > 1 && !(last && sum < a.clientW)) { unjustified.push({ day: row.day, sum, wall: a.clientW, last }); } for (const t of row.tiles) { if (t.ar !== null && Math.abs(t.w / t.h - t.ar) > 0.02) { unjustified.push({ day: row.day, name: t.name, ar: t.ar, drawn: Number((t.w / t.h).toFixed(3)) }); } } } // And the bands: a band names the day it stands over, and the frames under it are // the frames of that day. let head = null; for (const row of a.rows) { if (row.band) { head = row.day; if (head) seenDays.add(head); continue; } // The first row drawn may begin under a band the window left above the fold. if (!head) continue; if (row.day !== head) dayBad.push({ head, row: row.day }); for (const t of row.tiles) { if (t.name && head !== dayKeyOf(t.name)) dayBad.push({ head, name: t.name, own: dayKeyOf(t.name) }); } } } step('the frames fill the pane from its top edge to its bottom one', holes.length === 0, JSON.stringify(holes.slice(0, 2))); step('a row of frames is justified to the wall, and a day ends in a row of its own', unjustified.length === 0, JSON.stringify(unjustified.slice(0, 2))); step( 'every row of frames stands under the band that names its day', dayBad.length === 0 && seenDays.size > 1, `${seenDays.size} days through the window, ${JSON.stringify(dayBad.slice(0, 2))}` ); // Back to the head of the roll, where the month's own band is drawn. await scrollTo(0); await page.waitForTimeout(1200); // The two bands, at the heights the wall lays its shelf out in: both are fixed // numbers in the style sheet, and the shelf's whole height — and with it every // frame's own top — is counted from them, so a band that grew with its own text is // a shelf whose end moves. const bands = await page.evaluate(() => { const h = (sel) => { const el = document.querySelector(sel); return el ? Math.round(el.getBoundingClientRect().height) : null; }; return { month: h('.adm-month-head'), day: h('.adm-day-head') }; }); step('the month and the day bands stand at the heights the wall lays them in', bands.month === 44 && bands.day === 30, JSON.stringify(bands)); // The month's own band, over the first day of the month it names and over no // other: the wall is read by the month it was shot and only then by the day, and a // band left standing over a day of the month above it is a month named twice. const head = await audit(); const monthBad = []; let prev = null; let monthBands = 0; for (const row of head.rows) { if (!row.band) continue; const month = row.day.slice(0, 7); if (row.month) monthBands++; // The window's own first day is not read: it is a day the wall began in the // middle of, and the band above it was drawn above the fold or not at all. if (prev !== null && ((month !== prev) !== Boolean(row.month) || (row.month && row.month !== month))) { monthBad.push({ day: row.day, named: row.month, month }); } prev = month; } step('the wall names a month over the first day of it, and only there', monthBands >= 1 && monthBad.length === 0, `${monthBands} month bands, ${JSON.stringify(monthBad.slice(0, 2))}`); // The ruler down the right edge: a tick a month of the roll and a named tick a // year of it, on the shelf's own months and not on a calendar — a month nothing // was shot in is a month with no tick. const ticks = await page.evaluate(() => ({ name: document.querySelector('.lib-ruler-name')?.textContent?.trim() ?? '', years: [...document.querySelectorAll('.lib-ruler-tick[data-kind="year"]')].map((e) => (e.textContent ?? '').trim()), months: [...document.querySelectorAll('.lib-ruler-tick[data-kind="month"]')].map((e) => e.getAttribute('data-at')), })); step('the ruler stands beside the wall', ticks.name.length > 0, ticks.name); step('a tick a month of the roll', ticks.months.length === rollMonths.length, `${ticks.months.length} of ${rollMonths.length} months`); step('and a named tick a year of it', [...ticks.years].sort().join() === [...rollYears].sort().join(), `${ticks.years.join()} of ${rollYears.join()}`); // Pressing the ruler: a month's tick brings the band of that month to the top of // the wall, which is a month the wall had never drawn — the whole reason the // shelf's own layout is counted rather than measured off the boxes in the DOM. const press = await page.evaluate(() => { const list = [...document.querySelectorAll('.lib-ruler-tick[data-kind="month"]')]; const mid = list[Math.floor(list.length / 2)]; const tick = mid.getAttribute('data-key') ?? ''; return { tick, key: tick.replace('lib-ruler-', ''), at: Number(mid.getAttribute('data-at')) }; }); await page.click(`[data-key="${press.tick}"]`); await page.waitForTimeout(1500); const landed = await page.evaluate((key) => { const grid = document.querySelector('.adm-grid'); const band = document.querySelector(`[data-key="lib-month-${key}"]`); const on = document.querySelector('.lib-ruler-tick.on'); return { drawn: Boolean(band), offset: band && grid ? Math.round(band.getBoundingClientRect().top - grid.getBoundingClientRect().top) : null, value: document.querySelector('[data-key="lib-ruler-value"]')?.textContent?.trim() ?? '', on: on ? on.getAttribute('data-at') : '', }; }, press.key); step('a press on the ruler brings that month to the top', landed.drawn && landed.offset !== null && landed.offset >= -2 && landed.offset <= 18, `band at ${landed.offset}px`); step( 'and the ruler reads the month it was taken to', Number(landed.on) === press.at && landed.value.length > 0, `on=${landed.on} of ${press.at}, " ${landed.value}"` ); // A notch of the wheel, over the ruler: one month, whichever way the roll is read, // and never off the end of it. const notch = () => page.evaluate(() => { const months = [...document.querySelectorAll('.lib-ruler-tick')].filter((t) => t.getAttribute('data-kind') === 'month'); const on = months.find((t) => t.classList.contains('on')); const needle = document.querySelector('[data-key="lib-ruler-needle"]'); return { on: months.findIndex((t) => t.classList.contains('on')), count: months.length, needle: needle ? Number.parseFloat(needle.style.top) || 0 : 0, tick: on ? Number.parseFloat(on.style.top) || 0 : 0, }; }); const beforeNotch = await notch(); await page.evaluate(() => { const el = document.querySelector('[data-key="lib-ruler"]'); el.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true, cancelable: true })); }); await page.waitForTimeout(1500); const afterNotch = await notch(); step('a notch of the wheel is one month', afterNotch.on === beforeNotch.on + 1, `${beforeNotch.on} -> ${afterNotch.on} of ${beforeNotch.count}`); step('and the needle stands in the month the eye is in', Math.abs(afterNotch.needle - afterNotch.tick) < 0.6, `needle ${afterNotch.needle}% of tick ${afterNotch.tick}%`); // The shelf walked to its own end, a screen at a time — a jump straight to the // bottom is a different question, and the one this check exists for: the wall // draws the rows around the eye and stands in for the rest with its own height, so // a body standing shorter than the layout leaves the reader at the end of the // scroll on a screen of nothing and the rest of the roll never drawn at all. What // the walk is read for is that it *gets* there, standing on the last frame, and // that every frame of the roll was drawn on the way. const walked = await page.evaluate(async () => { const el = document.querySelector('.adm-grid'); const seen = new Set(); const collect = () => { for (const tile of el.querySelectorAll('.lib-tile')) seen.add(tile.getAttribute('data-key')); }; const breathe = () => new Promise((r) => setTimeout(r, 150)); el.scrollTop = 0; await breathe(); let guard = 0; while (guard++ < 400) { collect(); if (el.scrollTop + el.clientHeight >= el.scrollHeight - 2) break; el.scrollTop = el.scrollTop + Math.round(el.clientHeight * 0.8); await breathe(); } collect(); const body = el.querySelector('.adm-wall-body'); const tiles = [...el.querySelectorAll('.lib-tile')]; const last = tiles[tiles.length - 1]; return { top: Math.round(el.scrollTop), reached: el.scrollTop + el.clientHeight >= el.scrollHeight - 2, room: Math.round(el.scrollHeight - el.clientHeight), // What is left under the last frame drawn: the body that has nothing left to // stand in for. gap: last && body ? Math.round(body.getBoundingClientRect().bottom - last.getBoundingClientRect().bottom) : null, seen: [...seen], }; }); step('the shelf is walked to its own end', walked.reached, `stopped at ${walked.top} of ${walked.room}`); step('and stands on nothing there', walked.gap !== null && walked.gap <= 2, `${walked.gap}px of nothing under the last frame`); const missed = names.filter((name) => !walked.seen.includes(`lib-photo-${FOLDER}/${name}`)); step('every frame of the roll is drawn on the way', missed.length === 0, `${walked.seen.length} of ${names.length} drawn, first missed ${missed[0] ?? 'none'}`); // 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.tiles > 0 && looped.length === 0, `blank=${after.blank}, tiles=${after.tiles}, errors=${looped.length ? looped[0] : 'none'}` ); step('the wall is still the wall', after.tiles > 0, `${after.tiles} frames 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);