Files
RecipesCam/docker/frontend/scripts/library-check.mjs
T
3dtours c1ee2cf30a web: fold the whole roll from the head of the tree
A roll read to the bottom of its dates is a long column of indented rows, and
the only way to shut it was one row at a time — the click that folds a row also
opens it, which is the right trade for reading and a poor one for putting away.
The column's own name is now the whole tree's control: a right click there opens
a menu whose one item is COLLAPSE ALL / THU GỌN TẤT CẢ, and it folds every row
that has something under it at once — the root's own row included, so the tree
comes back to one line per picked folder and the subfolder two deep is away with
the rest. The menu is the folder menu's shape at a third subject rather than a
second menu: one `root` flag on the state that already knows where a right click
landed, and the same Escape, same click-away, same clamp to the window edge.

The frames do not move with the rows: folding is a way of looking at the tree,
and the strip already reads the open folder, which the fold leaves alone. The
name keeps reading as the folder it belongs to — the folder's label, with the
hint appended to its title — and the item is disabled rather than hidden when
nothing in the column has children, so a fresh folder does not open a menu with
a dead line in it.

Verified:
  library-check.mjs — 34 steps, all passed, 2 new: a right click on the root
    name offers exactly [lib-menu-collapse] while the name still reads "Roll A",
    and one click takes the fully open roll (4 rows: the picked folder, its
    subfolder, the subfolder under that, and one holding no frame) to a single
    row. The fold is measured against the ruler of opening the root back up:
    the 3 rows that return are CheckRoll, CheckRoll/2026 and CheckRoll/Empty —
    CheckRoll/2026/04 is still shut, which is what says the deep row went with
    the fold and not just the root. The strip held its 2 tiles throughout, and
    the folder the visit was on (lib-node-CheckRoll/2026) is what the reload
    below still reopens on.
  scan-nav-check.mjs — all steps passed; roll-walk-check.mjs — passed.
  frontend tsc --noEmit clean. Live 8090, served asset index-DZFlTBDG.js
  matching dist/: /, /library and /app all 200 and 0 console errors.

ponytail: only folding, no unfold-all to match — the click on a row already
opens it and the root's own row is one click away; add the pair when the column
routinely holds many picked folders and opening them one by one stops being
cheap.
2026-09-28 21:03:30 +07:00

529 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The catalogue, checked where it actually lives: in a browser. A directory
// handle, IndexedDB, a grid of tiles and a handover into the studio have no
// meaning outside one, so this is a Playwright run rather than a pure-node
// assert.
//
// Only Chromium can hand a page a folder, and only a person can answer the
// picker. The check stands in for both: it serves two real frames (one JPEG, one
// RAW) off a local server and answers `showDirectoryPicker` with a folder made of
// them. The stand-in handles carry their methods as *non-enumerable* properties,
// which is what lets them go through IndexedDB's structured clone at all (a
// clone copies enumerable own keys and drops the rest, and a function anywhere
// else is a DataCloneError); the methods are put back where the catalogue reads
// them, because a cloned handle is a plain object with no `getFile`.
//
// npm run build && node scripts/library-check.mjs
// PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> node scripts/library-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 SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..');
const APP_PORT = Number(process.env.APP_PORT ?? 4185);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
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 two frames, off a local server (the page fetches them by CORS) ------
const sampleServer = createServer(async (req, res) => {
const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0]));
if (name !== JPG && name !== RAW) {
res.writeHead(404).end();
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
// A beat per frame: the catalogue reads two files faster than a poll, and the
// ring that says "this roll is being read" needs a window to be caught in.
await new Promise((resolve) => setTimeout(resolve, 250));
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Access-Control-Allow-Origin': '*',
});
res.end(bytes);
});
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.
const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], {
cwd: FRONTEND,
stdio: 'ignore',
});
const base = `http://127.0.0.1:${APP_PORT}`;
let up = false;
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) {
app.kill('SIGTERM');
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({
// Playwright's own Chromium when it has one; CHROME points at any other.
executablePath: process.env.CHROME || undefined,
args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
});
const context = await browser.newContext();
// The folder the picker would have returned, plus the revival of its handles on
// the way back out of IndexedDB.
await context.addInitScript(
({ origin, names, subs, folder }) => {
const bytes = new Map();
const load = async (name) => {
if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer()));
return bytes.get(name);
};
const fileHandle = (name) => {
const handle = { kind: 'file', name, __fake: true };
Object.defineProperty(handle, 'getFile', {
value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }),
});
return handle;
};
// The picked folder holds `names` at its top level and the subfolders in
// `subs`, each `{ files, dirs }` — a roll really is a folder of dated
// directories inside dated directories, which is what the tree column walks.
const dirHandle = (label, node) => {
const handle = { kind: 'directory', name: label };
Object.defineProperties(handle, {
values: {
value: () =>
[
...(node.files ?? []).map(fileHandle),
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
][Symbol.iterator](),
},
queryPermission: { value: async () => 'granted' },
requestPermission: { value: async () => 'granted' },
});
return handle;
};
const root = () => dirHandle(folder, { files: names, dirs: subs });
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: [JPG],
// One frame at the top, one a folder down, one two folders down: the walk has
// to reach the bottom of the roll, not just its first level.
// One folder down, one two deep, and one holding no frame the catalogue can
// read: the walk names it long before there is a thumbnail for it.
subs: {
2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } },
Empty: { files: ['notes.txt'], dirs: {} },
},
folder: 'CheckRoll',
}
);
const page = await context.newPage();
const catalogue = () =>
page.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const all = (store) =>
new Promise((resolve, reject) => {
const req = db.transaction(store).objectStore(store).getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
const [photos, edits] = await Promise.all([all('photos'), all('edits')]);
const thumbs = [];
for (const photo of photos) {
const head = photo.thumb ? new Uint8Array(await photo.thumb.slice(0, 2).arrayBuffer()) : null;
thumbs.push({
id: photo.id,
bytes: photo.thumb?.size ?? 0,
jpeg: head ? head[0] === 0xff && head[1] === 0xd8 : false,
});
}
return { thumbs, edits: edits.map((e) => ({ id: e.photoId, filter: e.recipe?.baseFilter, knobs: Object.keys(e.recipe?.adjustments ?? {}).length })) };
});
// --- the run ----------------------------------------------------------------
await page.goto(`${base}/library`);
await page.click('[data-key="lib-add"]');
// A folder that is being read says so on its own row, not only in the toolbar.
const ringed = await page
.waitForFunction(
() => {
const row = document.querySelector('.adm-album[aria-busy="true"]');
return row?.querySelector('.lib-spin') ? row.dataset.key : null;
},
null,
{ timeout: 10_000, polling: 50 }
)
.then((handle) => handle.jsonValue())
.catch(() => null);
step('the folder being read turns a ring on its row', ringed === 'lib-node-CheckRoll', String(ringed));
const thumbs = '[data-key^="lib-thumb-"]';
await page.waitForSelector(thumbs, { timeout: 120_000 });
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 120_000 });
step('every frame of the roll is indexed, two folders down', true, `3 tiles from ${JPG} ×2 + ${RAW}`);
const indexed = await catalogue();
for (const name of [JPG, RAW]) {
const row = indexed.thumbs.find((t) => t.id.endsWith(`/${name}`));
step(`thumbnail for ${name}`, !!row && row.bytes > 1000 && row.jpeg, `${row?.bytes ?? 0} bytes, jpeg=${row?.jpeg}`);
}
// The tree: the picked folder, with the subfolder a frame sits in under it, each
// carrying the frames at or below it. The RAW is the nested one.
const nodes = await page.$$eval('[data-key^="lib-node-"]', (els) =>
els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent])
);
step(
'the tree draws the roll as folders under folders',
JSON.stringify(nodes) ===
JSON.stringify([
['lib-node-CheckRoll', '3'],
['lib-node-CheckRoll/2026', '2'],
['lib-node-CheckRoll/2026/04', '1'],
// Named by the walk, counted by nothing: a row before a frame.
['lib-node-CheckRoll/Empty', '0'],
]),
JSON.stringify(nodes)
);
// A row with children folds its subtree on the same click that opens it, and the
// frames it holds stay in the strip — the tree narrows, the pictures do not.
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, {
timeout: 10_000,
polling: 50,
});
const foldedThumbs = await page.$$eval(thumbs, (els) => els.length);
const foldedMeta = await page.locator('[data-key="lib-stage-meta"]').innerText();
const shut = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded');
step(
'a folder folds its subtree and keeps its frames in the strip',
foldedThumbs === 3 && shut === 'false' && /P1010256/.test(foldedMeta),
`${foldedThumbs} tiles, aria-expanded=${shut}, stage ${foldedMeta.split('\n')[0]}`
);
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, {
timeout: 10_000,
polling: 50,
});
step('the same click draws it open again', true, '4 rows');
// Show subfolders or not: the strip follows the open folder alone, or the whole
// branch under it. The switch is the reader's and the tree does not change with
// it — folding is about rows, this is about frames.
await page.click('[data-key="lib-subs"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 1, thumbs, { timeout: 10_000, polling: 50 });
const shallow = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length);
const shallowStage = await page.locator('[data-key="lib-stage-meta"]').innerText();
step(
'the switch leaves the open folder its own frames',
shallow === 4 && /P1010256\.JPG/.test(shallowStage),
`${shallow} rows, stage ${shallowStage.split('\n')[0]}`
);
await page.click('[data-key="lib-subs"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 10_000, polling: 50 });
step('and puts the branch back', true, '3 tiles from the roll below');
// A folder the walk named and the catalogue found nothing in: the row is there,
// the strip says so rather than drawing nothing at all.
await page.click('[data-key="lib-node-CheckRoll/Empty"]');
await page.waitForFunction(() => document.querySelector('.adm-filmstrip .adm-row-empty') !== null, null, {
timeout: 10_000,
polling: 50,
});
const emptyThumbs = await page.$$eval(thumbs, (els) => els.length);
const emptyHint = await page.$eval('.adm-filmstrip .adm-row-empty', (e) => e.textContent);
step(
'a folder the walk named but holds no frame draws an empty strip',
emptyThumbs === 0 && /chưa có ảnh|nothing in this folder/i.test(emptyHint ?? ''),
`${emptyThumbs} tiles, "${emptyHint}"`
);
// Back to the roll: the first click folds it on the way, so it takes two.
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, {
timeout: 10_000,
polling: 50,
});
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, {
timeout: 10_000,
polling: 50,
});
step('and the roll comes back with its frames', true, '3 tiles');
// Opening the subfolder redraws the strip and the frame above it.
await page.click('[data-key="lib-node-CheckRoll/2026"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 30_000 });
const staged = await page.locator('[data-key="lib-stage-meta"]').innerText();
step('the open subfolder draws its own strip and frame', /P1010256\.(RW2|JPG)/.test(staged), staged.replace(/\n/g, ' · '));
// The switch: the middle becomes every thumbnail of the open node at once.
await page.click('[data-key="lib-view-grid"]');
await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 });
const cards = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key));
step('the grid view draws a card per frame', cards.length === 2, JSON.stringify(cards));
await page.click('[data-key="lib-view-preview"]');
await page.waitForSelector(thumbs, { timeout: 30_000 });
// The toolbar carries the screen's one button and the two view icons on a single
// row; a folder's own rescan and remove exist only once it has been right-clicked.
const bar = await page.$eval('.lib-bar', (el) => ({
rows: new Set(
[...el.children].map((c) => {
const box = c.getBoundingClientRect();
return Math.round(box.top + box.height / 2);
})
).size,
icons: [...el.querySelectorAll('[data-key^="lib-view-"]')].every((b) => b.querySelector('svg')),
}));
step('one toolbar row holding the two view icons', bar.rows === 1 && bar.icons, JSON.stringify(bar));
step('no rescan/remove button in the pane', (await page.$$('[data-key^="lib-rescan-"], [data-key^="lib-drop-"]')).length === 0);
await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu));
await page.keyboard.press('Escape');
step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null);
// The column is names and counts, nothing else: the count rides on the row.
const column = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
step(
'no FOLDERS heading, the count on the folder row',
(await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '3',
`${column}px column, stage ${await page.$eval('[data-key="lib-stage"]', (e) => Math.round(e.getBoundingClientRect().width))}px`
);
step('the folder column stays out of the stage\'s way', column <= 130, `${column}px`);
// A rename paints a label over the folder: the row it belongs to, and the frames
// under it, do not move — the node key is still the directory's own name.
page.once('dialog', (dialog) => dialog.accept('Roll A'));
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-rename-CheckRoll"]', { timeout: 10_000 });
await page.click('[data-key="lib-rename-CheckRoll"]');
await page.waitForFunction(() => document.querySelector('[data-key="lib-node-CheckRoll"] .adm-album-name')?.textContent === 'Roll A', null, {
timeout: 10_000,
});
step(
'a folder can be renamed without being moved',
(await page.$('[data-key="lib-node-CheckRoll/2026"]')) !== null,
await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent)
);
step(
'the column is titled with the folder the tree belongs to',
(await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)) === 'Roll A',
await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)
);
// The empty part of the column is where one more folder comes from.
await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, y: 200 } });
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank));
await page.keyboard.press('Escape');
step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null);
// The head of the tree is the whole tree's control: a right click on the column's
// own name folds every row that has something under it, so a roll read to the
// bottom of its dates comes back to its own line. The frames in the strip are the
// strip's business and do not move with it. The subfolder opened above carries a
// fold of its own, so it is opened first: the claim is what ONE click does to
// every row at once, not to the one that was already shut.
await page.click('[data-key="lib-node-CheckRoll/2026"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 4, null, {
timeout: 10_000,
polling: 50,
});
const deepRows = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length);
const deepTiles = await page.$$eval(thumbs, (els) => els.length);
await page.click('[data-key="lib-root-name"]', { button: 'right' });
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
const head = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
step('a right click on the root name offers to fold the tree', head.join(',') === 'lib-menu-collapse', JSON.stringify(head));
step(
'the column name still reads as the folder it belongs to',
(await page.$eval('[data-key="lib-root-name"]', (e) => e.textContent)) === 'Roll A',
await page.getAttribute('[data-key="lib-root-name"]', 'title')
);
await page.click('[data-key="lib-menu-collapse"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 1, null, {
timeout: 10_000,
polling: 50,
});
const flatTiles = await page.$$eval(thumbs, (els) => els.length);
const shutRoot = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded');
// Opening the root back up is the ruler for the rest: only the rows the fold shut
// are still away, and the subfolder two deep is one of them.
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, {
timeout: 10_000,
polling: 50,
});
const revealed = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key));
step(
'and one click folds every row that has something under it',
deepRows === 4 && shutRoot === 'false' && flatTiles === deepTiles && revealed.join(',') === 'lib-node-CheckRoll,lib-node-CheckRoll/2026,lib-node-CheckRoll/Empty',
`${deepRows} rows → 1, ${revealed.length} back with the root opened, ${flatTiles} tiles kept`,
);
// The open folder is the visit's, not the fold's, so it is put back where the
// sections below left it.
await page.click('[data-key="lib-node-CheckRoll/2026"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 30_000 });
// The divider drags the column, and what the visit leaves behind — the width the
// column was left at and the folder that was open — is what the next visit opens
// with. The right click on the empty column answered with a folder, not with a
// folder opened, so the open node is still the subfolder chosen above.
const gripBox = await (await page.$('[data-key="lib-grip"]')).boundingBox();
await page.mouse.move(gripBox.x + 4, gripBox.y + 120);
await page.mouse.down();
await page.mouse.move(gripBox.x + 84, gripBox.y + 120, { steps: 6 });
await page.mouse.up();
const dragged = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
await page.reload();
await page.waitForSelector(thumbs, { timeout: 120_000 });
const reopened = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width));
const where = await page.$eval('[data-key^="lib-node-"][aria-current="true"]', (e) => e.dataset.key);
step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`);
step('the screen reopens on the folder it was left on', where === 'lib-node-CheckRoll/2026', where);
// One tap on a thumbnail raises the frame, and the button under it hands it to
// the studio. The RAW is the harder half — it has to be read back off the
// (stand-in) handle and developed — so it is the one that gets opened.
const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id;
await page.click(`[data-key="lib-thumb-${rawId}"]`);
await page.click('[data-key="lib-open"]');
await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 });
await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 });
step('studio developed the frame from its handle', true, 'stage no longer on the drop zone');
// The handover is an address, not a state: the studio puts the frame into its
// own store and drops the query, so a reload reopens it like any other photo.
step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`);
const editsIn = () =>
page.evaluate(async () => {
const db = await new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return await new Promise((resolve) => {
const req = db.transaction('edits').objectStore('edits').getAll();
req.onsuccess = () => resolve(req.result);
});
});
// The recipe is written 600ms after the last change (a slider drag fires it per
// frame), so the row arrives a beat after the frame does.
let filed = [];
for (let i = 0; i < 40 && !filed.length; i++) {
filed = await editsIn();
if (!filed.length) await new Promise((r) => setTimeout(r, 250));
}
step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed));
await page.goto(`${base}/library`);
await page.waitForSelector(`[data-key="lib-thumb-${rawId}"]`);
await page.click(`[data-key="lib-thumb-${rawId}"]`);
const meta = await page.locator('[data-key="lib-stage-meta"]').innerText();
step('the frame says it was edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · '));
// The strip is a shelf, not a page: when the open folder holds more frames than
// fit, a plain wheel tick over it walks them along — a wheel has no sideways
// axis to scroll a row with. The window is narrowed until the three tiles of the
// open roll no longer fit, which is what a long roll looks like anyway.
await page.click('[data-key="lib-node-CheckRoll"]');
await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 3, thumbs, { timeout: 30_000 });
await page.setViewportSize({ width: 320, height: 720 });
await page.waitForTimeout(200);
const shelf = await page.$eval('[data-key="lib-strip"]', (el) => ({ sw: el.scrollWidth, cw: el.clientWidth }));
const shelfBox = await (await page.$('[data-key="lib-strip"]')).boundingBox();
await page.mouse.move(shelfBox.x + shelfBox.width / 2, shelfBox.y + shelfBox.height / 2);
await page.mouse.wheel(0, 200);
await page.waitForTimeout(300);
const walked = await page.$eval('[data-key="lib-strip"]', (el) => el.scrollLeft);
step('a wheel tick walks the strip along', shelf.sw > shelf.cw && walked > 0, `${shelf.sw}px of tiles in ${shelf.cw}px, scrolled to ${walked}`);
// A catalogue another tab has open — the tab that is still running yesterday's
// build, holding the connection the new build used to ask for a version on. The
// screen has to come up on it: the catalogue is opened as it is, with no version
// to reach for and nothing for the other tab to block.
const hold = await context.newPage();
await hold.route('**/assets/*.js', (r) => r.abort());
await hold.goto(`${base}/library`).catch(() => {});const held = await hold.evaluate(
() =>
new Promise((resolve, reject) => {
const req = indexedDB.open('recipescam-library');
req.onsuccess = () => {
window.__held = req.result;
resolve(req.result.version);
};
req.onerror = () => reject(req.error);
})
);
await page.setViewportSize({ width: 1280, height: 720 });
await page.goto(`${base}/library`);
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 });
const seenAgain = await page.$$eval(thumbs, (els) => els.length);
step(
'a catalogue another tab is holding still comes up',
held >= 1 && seenAgain === 3,
`held at version ${held}, ${seenAgain} tiles on the other tab`
);
await browser.close();
sampleServer.close();
app.kill('SIGTERM');
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
process.exit(failures ? 1 : 0);