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

591 lines
28 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;
// 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=<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)));
// 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);