Files
RecipesCam/docker/frontend/scripts/wall-check.mjs
T

389 lines
18 KiB
JavaScript

// 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=<path to playwright-core/index.mjs> 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 DAY0 = Date.UTC(2026, 0, 1);
// 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;
};
const dayOfName = (name) => DAY0 + dayIndex(Number((name.match(/\d+/) ?? ['1'])[0]) - 1) * 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=<path to its index.mjs>');
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();
// The first screenful is one day of a mixed roll often as not — a day big enough
// to fill it is a day at all — so what is read here is that a band stands over
// the cards and names its day, and the cut into several days is read below, on a
// wall scrolled off the fold.
step('the wall draws its days over the cards', days1.sections >= 1 && days1.labelled === days1.sections, `${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)));
// 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.
// Read down the shelf rather than at one stop: a stop of any one fraction lands
// inside a day big enough to fill the screen as often as it lands on a fold, so
// what is read is that no band ever files a card under another day's header, and
// that the shelf's own cuts move through the window as it travels.
const stops = [0.15, 0.35, 0.55, 0.75, 0.95];
const seenDays = new Set();
let scrolledBad = [];
for (const at of stops) {
await page.evaluate((f) => {
const el = document.querySelector('.adm-grid');
el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) * f);
el.dispatchEvent(new Event('scroll'));
}, at);
await page.waitForTimeout(900);
const audit = await dayAudit();
for (const section of await page.$$eval('.adm-day', (els) => els.map((e) => e.getAttribute('data-key')))) seenDays.add(section);
scrolledBad = scrolledBad.concat(audit.bad);
}
step(
'a scrolled wall still files every card under its own day',
scrolledBad.length === 0 && seenDays.size > 1,
`${seenDays.size} days through the window, ${JSON.stringify(scrolledBad.slice(0, 2))}`
);
// 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
// stands in for the frames it is not drawing, and a spacer standing in for more
// rows than those frames take leaves the reader at the end of the scroll with a
// screen of nothing under the last card drawn 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 card of el.querySelectorAll('.adm-card')) seen.add(card.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 cards = [...el.querySelectorAll('.adm-card')];
const last = cards[cards.length - 1];
const box = el.getBoundingClientRect();
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 card drawn: the spacer that has nothing left
// to stand in for. A few pixels is the grid's own gap.
gap: last ? Math.round(el.scrollHeight - (last.getBoundingClientRect().bottom - box.top + el.scrollTop)) : 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 <= 16, `${walked.gap}px of nothing under the last card`);
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.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);