753eea0d7b
The reader opens a roll of a few thousand frames and holds Ctrl+Shift+R while
it is being read — over a reading that takes minutes, that is the one thing
they do. What came back was a reading that started again at the top: every
folder listed a second time, the toolbar counter back at zero, the frames that
were already in the catalogue walked over so that their size and their write
time could say they had not moved.
None of that was in the catalogue, because the catalogue only holds the frames
that were read. What was lost with the page was the reading's position: the
folders the walk had been through, the folders still in its queue, and the
frames it had found and not yet read. The catalogue alone can never answer
where a reading was.
The position is now written to session storage as the reading goes — walked,
pending and the frames in hand — and read back on the way in. Session storage
and not local storage, because a position belongs to the tab: the tab that
reloads carries on from the frame it stopped at, and a tab opened beside it
starts a reading of its own. The key goes when the reading finishes, and goes
with the folder when the folder is removed.
The frames in hand are the part worth spelling out. A frame a lane is in the
middle of, and a frame whose row is in the batch and not yet in the catalogue,
are on neither side of the line the position is written on: the catalogue does
not hold them and the walk will not find them again, so both are written into
the queue and read again. They also were counted when they left the queue, and
the count is written with them — else the reloaded reading would count them a
second time. The counter now carries on from where it was instead of restarting
from zero.
One thing was hidden behind the other: the stand-in folder the check hands the
page had no `getFileHandle` and no `getDirectoryHandle`, so the path that asks
the root for the frames a position names threw and answered nothing — and the
reading then walked the roll from the top, exactly the behaviour the check was
meant to catch. The check's folder answers both now, the way the browser's does.
Verified:
library-check.mjs — 45 steps, all passed, two of them new. The reading is
stopped on the frame it is reading (`check.hold`), the tab is reloaded, and
it comes back at "Scanning 3/3 — 0 new…" — the same line it was stopped at,
not a reading starting over — with the position still holding 4 folders
walked, an empty queue and 3 frames in hand. Listing 3 tiles and letting the
key go, the reading finishes the roll: 3 tiles in the strip, the catalogue
written, the position cleared, and every folder listed once
({"2026":1,"CheckRoll":1,"Empty":1,"04":1}) where a reading that started
over lists each of them twice.
scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
clean, vite build clean.
ponytail: the position is the tab's, so a reload keeps it and a new tab does
not — the tab that reloaded is the one the reader is looking at, and a second
tab reading the same roll from the top costs a walk and no bytes, because a
frame that has not moved is dropped on its size and its time. The position is
written at the batch beat, so a reload reads at most one batch again, and the
frames in hand are read again on purpose: their rows were never stored.
Co-authored-by: PenguinHarness <noreply@penguin.local>
784 lines
39 KiB
JavaScript
784 lines
39 KiB
JavaScript
// 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;
|
||
}
|
||
// The mark each command of an open context menu leads with. Every command carries
|
||
// one of its own: a column of marks reads at a glance where a block of uppercase
|
||
// does not, and a mark the platform's fonts have no glyph for is a box.
|
||
const menuMarks = () =>
|
||
page.$$eval('[data-key="lib-menu"] .lib-menu-item', (els) =>
|
||
els.map((e) => (e.querySelector('.lib-menu-icon')?.textContent ?? '').trim())
|
||
);
|
||
const marked = (marks) => marks.length > 0 && marks.every((m) => [...m].length === 1) && new Set(marks).size === marks.length;
|
||
|
||
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 }) => {
|
||
// A folder the browser will not let the page read is the one state a run of
|
||
// its own has to ask for: the check sets the key, reloads, and the handles
|
||
// come back refused.
|
||
window.__deny = localStorage.getItem('check.deny') === '1';
|
||
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);
|
||
};
|
||
// What a scan costs is the bytes it reads, so they are counted: a part cut
|
||
// off a file remembers the file it came off, and every `arrayBuffer` call on
|
||
// the whole or the part lands on that file's name. A frame the catalogue
|
||
// already holds is left on its size and its write time without a byte of it
|
||
// being read, which is the claim the run below makes.
|
||
const reads = (window.__reads = {});
|
||
const off = new WeakMap();
|
||
const part = Blob.prototype.slice;
|
||
Blob.prototype.slice = function (...args) {
|
||
const cut = part.apply(this, args);
|
||
off.set(cut, off.get(this) ?? this.name ?? '');
|
||
return cut;
|
||
};
|
||
const pull = Blob.prototype.arrayBuffer;
|
||
Blob.prototype.arrayBuffer = function () {
|
||
const name = off.get(this) ?? this.name ?? '';
|
||
if (name) reads[name] = (reads[name] ?? 0) + 1;
|
||
return pull.apply(this);
|
||
};
|
||
const fileHandle = (name) => {
|
||
const handle = { kind: 'file', name, __fake: true };
|
||
Object.defineProperty(handle, 'getFile', {
|
||
value: async () => {
|
||
const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}');
|
||
gets[name] = (gets[name] ?? 0) + 1;
|
||
sessionStorage.setItem('check.gets', JSON.stringify(gets));
|
||
// A reading caught in the middle of a frame: the check sets the key, the
|
||
// scan stops on the frame it was reading, and the next visit — with the
|
||
// key cleared — is the one that goes on.
|
||
while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25));
|
||
return new File([await load(name)], name, {
|
||
type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream',
|
||
// A real handle reports one write time for one file, and the same one
|
||
// on every read; a fresh `Date.now()` per call would make every frame
|
||
// look touched and hide what is being checked.
|
||
lastModified: Date.UTC(2026, 0, 1),
|
||
});
|
||
},
|
||
});
|
||
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.
|
||
// `__fake` on both kinds is what the revival below goes by: a handle that
|
||
// comes back out of IndexedDB is a plain object with no methods on it, and a
|
||
// folder that is not revived is a folder the screen has no permission for.
|
||
const dirHandle = (label, node) => {
|
||
const handle = { kind: 'directory', name: label, __fake: true };
|
||
Object.defineProperties(handle, {
|
||
values: {
|
||
value: () => {
|
||
// Every listing a reading asks for, counted across reloads: a reading
|
||
// that starts over lists the same folder twice, and that is the one
|
||
// thing on screen a reader reads as "it threw the catalogue away".
|
||
const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}');
|
||
lists[label] = (lists[label] ?? 0) + 1;
|
||
sessionStorage.setItem('check.lists', JSON.stringify(lists));
|
||
return [
|
||
...(node.files ?? []).map(fileHandle),
|
||
...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)),
|
||
][Symbol.iterator]();
|
||
},
|
||
},
|
||
queryPermission: { value: async () => (window.__deny ? 'denied' : 'granted') },
|
||
requestPermission: { value: async () => (window.__deny ? 'denied' : 'granted') },
|
||
// A folder is asked for a child by name as often as it is listed: the
|
||
// reading spells its queue as paths, and a path comes back as a handle
|
||
// this way. A name that is not there is the error the browser gives.
|
||
getDirectoryHandle: {
|
||
value: async (name) => {
|
||
const kid = node.dirs?.[name];
|
||
if (!kid) throw new Error(`no folder named ${name}`);
|
||
return dirHandle(name, kid);
|
||
},
|
||
},
|
||
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, 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));
|
||
// Every command leads with a mark of its own, the way the tool rail and the view
|
||
// switch do: a column of marks reads at a glance where a block of uppercase does
|
||
// not, and a mark that is not in the font the platform ships is a box.
|
||
const marks = await menuMarks();
|
||
step(
|
||
'every command in the folder menu leads with its own mark',
|
||
marks.length === menu.length && marked(marks),
|
||
JSON.stringify(marks)
|
||
);
|
||
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)
|
||
);
|
||
// The folder's name is the row's, said once: a heading over the column would only
|
||
// repeat it, and that heading would outlive the rows when the tree is folded.
|
||
step(
|
||
'the folder is named on its row and nowhere over the column',
|
||
(await page.$$('[data-key="lib-root-name"], .lib-col-title')).length === 0 &&
|
||
(await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-name', (e) => e.textContent)) === 'Roll A'
|
||
);
|
||
|
||
// 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));
|
||
const blankMarks = await menuMarks();
|
||
step('and that one command carries its mark too', blankMarks.length === 1 && marked(blankMarks), JSON.stringify(blankMarks));
|
||
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 row of a
|
||
// folder picked at the top level folds the folders nested below the top level. The
|
||
// top-level row itself stays open — one click on its name already shuts it, so the
|
||
// menu item would only repeat that click — and
|
||
// the frames in the strip are the strip's business and do not move with the rows.
|
||
// 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 one 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);
|
||
// The row is where a reader right-clicks, so fold-the-tree leads that row's menu.
|
||
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
||
await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 });
|
||
const rowMenu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
||
step(
|
||
'a right click on the top-level folder row offers to fold the tree',
|
||
rowMenu.join(',') === 'lib-menu-collapse,lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll',
|
||
JSON.stringify(rowMenu),
|
||
);
|
||
const rowMarks = await menuMarks();
|
||
step('and its four commands each lead with a mark of their own', rowMarks.length === 4 && marked(rowMarks), JSON.stringify(rowMarks));
|
||
await page.click('[data-key="lib-menu-collapse"]');
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length === 3, null, {
|
||
timeout: 10_000,
|
||
polling: 50,
|
||
});
|
||
const flatTiles = await page.$$eval(thumbs, (els) => els.length);
|
||
const ended = await page.$$eval('[data-key^="lib-node-"]', (els) =>
|
||
els.map((e) => `${e.dataset.key}:${e.getAttribute('aria-expanded') ?? '-'}`)
|
||
);
|
||
step(
|
||
'and one click folds the subfolders, leaving the top-level row open',
|
||
deepRows === 4 &&
|
||
flatTiles === deepTiles &&
|
||
ended.join(' ') === 'lib-node-CheckRoll:true lib-node-CheckRoll/2026:false lib-node-CheckRoll/Empty:-',
|
||
`${deepRows} rows → ${ended.length}, ${ended.join(' ')}, ${flatTiles} tiles kept`,
|
||
);
|
||
|
||
// 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);
|
||
|
||
// The frame that was raised is remembered too: a reader who comes back finds the
|
||
// negative they were looking at, not the first one the node happens to hold. The
|
||
// JPEG is the one with a picture to show, so it is the one that gets raised.
|
||
const jpgId = indexed.thumbs.find((t) => t.id.endsWith(`/${JPG}`) && t.id.includes('/2026/04/')).id;
|
||
await page.click(`[data-key="lib-thumb-${jpgId}"]`);
|
||
await page.reload();
|
||
await page.waitForSelector(thumbs, { timeout: 120_000 });
|
||
const raised = await page.$eval('.adm-thumb.on', (e) => e.dataset.key);
|
||
step('the screen reopens on the frame that was raised', raised === `lib-thumb-${jpgId}`, raised);
|
||
|
||
// A wheel tick over the frame magnifies it about the point under the pointer —
|
||
// the one gesture that makes a negative worth the whole window. The page under it
|
||
// holds still, and ticking back down fits the frame again.
|
||
const shotBox = await (await page.$('[data-key="lib-stage-img"]')).boundingBox();
|
||
await page.mouse.move(shotBox.x + shotBox.width / 3, shotBox.y + shotBox.height / 3);
|
||
await page.mouse.wheel(0, -240);
|
||
await page.waitForTimeout(150);
|
||
const magnified = await page.$eval('[data-key="lib-stage-img"]', (el) => ({
|
||
transform: getComputedStyle(el).transform,
|
||
origin: getComputedStyle(el).transformOrigin,
|
||
}));
|
||
await page.mouse.wheel(0, 600);
|
||
await page.waitForTimeout(150);
|
||
const fitted = await page.$eval('[data-key="lib-stage-img"]', (el) => getComputedStyle(el).transform);
|
||
const scale = Number((magnified.transform.match(/matrix\(([\d.]+)/) ?? [])[1] ?? 1);
|
||
step(
|
||
'a wheel tick over the frame zooms it, and back down fits it again',
|
||
scale > 1 && scale <= 6 && fitted === 'none',
|
||
`${magnified.transform} about ${magnified.origin} → ${fitted}`
|
||
);
|
||
|
||
// 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()}`);
|
||
|
||
// Two things a reader and an operator read straight off the header: the preset
|
||
// the studio has open wears the theme's accent (the same colour the brand and
|
||
// the open tab carry), and the rail's foot names the build this page came from.
|
||
await page.waitForSelector('.rail-version', { timeout: 15_000 });
|
||
const chrome = await page.evaluate(() => ({
|
||
version: document.querySelector('.rail-version')?.textContent?.trim() ?? '',
|
||
recipe: getComputedStyle(document.querySelector('.recipe-name')).color,
|
||
accent: getComputedStyle(document.querySelector('.brand em')).color,
|
||
}));
|
||
step(
|
||
'the rail foot names the build the page came from',
|
||
/^\d+\.\d+\.\d+\+\S+$/.test(chrome.version),
|
||
chrome.version
|
||
);
|
||
step(
|
||
'the preset chip in the header wears the theme accent, not a fixed grey',
|
||
chrome.recipe === chrome.accent,
|
||
`${chrome.recipe}, brand accent ${chrome.accent}`
|
||
);
|
||
|
||
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`
|
||
);
|
||
|
||
// A reading that was cut short — the app closed, the tab was gone — is finished by
|
||
// the next visit with no one asking for it: the walk starts on the way in, the
|
||
// frame the catalogue still holds is left on its size and its write time, and the
|
||
// two it never got to are read. Two rows are taken out to stand for a scan that
|
||
// stopped before it reached them.
|
||
await page.evaluate(async (keep) => {
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
const rows = await new Promise((resolve) => {
|
||
const req = db.transaction('photos').objectStore('photos').getAll();
|
||
req.onsuccess = () => resolve(req.result);
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction('photos', 'readwrite');
|
||
for (const row of rows) if (!row.name.endsWith(keep)) tx.objectStore('photos').delete(row.id);
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
}, RAW);
|
||
await page.reload();
|
||
await page
|
||
.waitForFunction((n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n, 3, { timeout: 60_000, polling: 50 })
|
||
.catch(() => {});
|
||
const restored = await page.$$eval(thumbs, (els) => els.length).catch(() => 0);
|
||
const read = await page.evaluate(() => window.__reads ?? {});
|
||
step(
|
||
'an interrupted reading goes on by itself, over the frames it still holds',
|
||
restored === 3 && !read[RAW] && read[JPG] > 0,
|
||
`${restored} tiles, ${JSON.stringify(read)}`
|
||
);
|
||
|
||
// The last of the six marks is the one only a folder whose permission has lapsed
|
||
// ever shows — and the one command that can do anything about it. The folder comes
|
||
// back refused, offers its way back, and takes it.
|
||
await page.evaluate(() => localStorage.setItem('check.deny', '1'));
|
||
await page.reload();
|
||
await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 });
|
||
await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' });
|
||
await page.waitForSelector('[data-key="lib-reconnect-CheckRoll"]', { timeout: 10_000 });
|
||
const refused = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key));
|
||
const refusedMarks = await menuMarks();
|
||
step(
|
||
'a folder the browser refuses offers the one command that answers it, marked',
|
||
refused.join(',') === 'lib-menu-collapse,lib-reconnect-CheckRoll' && marked(refusedMarks) && refusedMarks[1] === '⚿',
|
||
`${JSON.stringify(refused)} ${JSON.stringify(refusedMarks)}`
|
||
);
|
||
await page.evaluate(() => localStorage.removeItem('check.deny'));
|
||
await page.evaluate(() => (window.__deny = false));
|
||
await page.click('[data-key="lib-reconnect-CheckRoll"]');
|
||
await page
|
||
.waitForFunction(() => document.querySelectorAll('[data-key^="lib-thumb-"]').length === 3, null, { timeout: 60_000, polling: 50 })
|
||
.catch(() => {});
|
||
step('and taking it reads the folder again', (await page.$$eval(thumbs, (els) => els.length)) === 3);
|
||
|
||
// A reload in the middle of a reading — the reader holding Ctrl+Shift+R over a
|
||
// roll of a few thousand frames, which is the one thing they do while a reading
|
||
// runs. The catalogue holds the frames the reading got through, but the reading's
|
||
// own position is not in the catalogue: without it the walk starts at the top of
|
||
// the roll again, every folder is listed a second time, and the counter starts
|
||
// from zero while the frames it already holds are read again to be skipped.
|
||
//
|
||
// The position belongs to the tab now, so the reading carries on instead of
|
||
// starting over. `check.hold` stops the reading on the frame it is reading, which
|
||
// is where a reload lands — and the reading it left behind is still there when the
|
||
// key goes, so the same reading finishes the roll. Everything is taken out of the
|
||
// catalogue first, so the reading has frames on both sides of the reload.
|
||
await page.evaluate(async () => {
|
||
sessionStorage.clear();
|
||
localStorage.setItem('check.hold', '1');
|
||
const db = await new Promise((resolve, reject) => {
|
||
const req = indexedDB.open('recipescam-library');
|
||
req.onsuccess = () => resolve(req.result);
|
||
req.onerror = () => reject(req.error);
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
const tx = db.transaction('photos', 'readwrite');
|
||
tx.objectStore('photos').clear();
|
||
tx.oncomplete = () => resolve();
|
||
tx.onerror = () => reject(tx.error);
|
||
});
|
||
});
|
||
// The position the tab is holding, the line the toolbar reads out, and the wait
|
||
// for it: the line is how far the reading has come, and it is the one thing on
|
||
// screen that says whether a reading started over.
|
||
const position = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('recipescam.library.walk.CheckRoll') ?? 'null'));
|
||
const readout = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => '');
|
||
const counting = () =>
|
||
page.waitForFunction(
|
||
() => /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''),
|
||
null,
|
||
{ timeout: 30_000, polling: 50 }
|
||
);
|
||
await page.reload();
|
||
await counting().catch(() => {});
|
||
const stopped = await readout();
|
||
await page.reload();
|
||
await counting().catch(() => {});
|
||
const resumed = await readout();
|
||
const at = await position();
|
||
step(
|
||
'a reading a reload cut in half goes on from where it was',
|
||
/\d+\/\d+/.test(stopped) && resumed === stopped && at?.walked.length === 4 && at.pending.length === 0 && at.frames.length === 3,
|
||
`stopped at "${stopped.trim()}", back at "${resumed.trim()}", position ${JSON.stringify(at && { walked: at.walked.length, pending: at.pending.length, frames: at.frames.length })}`
|
||
);
|
||
|
||
// The key goes, and the reading the reload interrupted finishes the roll on its
|
||
// own: the frames it had found are read, the folders are not listed again — the
|
||
// check counts every listing a reading asks for, across reloads — and the position
|
||
// is dropped once there is nothing left to carry on from.
|
||
await page.evaluate(() => localStorage.removeItem('check.hold'));
|
||
await page
|
||
.waitForFunction(() => document.querySelectorAll('[data-key^="lib-thumb-"]').length === 3, null, { timeout: 60_000, polling: 50 })
|
||
.catch(() => {});
|
||
const carried = await page.$$eval(thumbs, (els) => els.length).catch(() => 0);
|
||
const again = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'));
|
||
step(
|
||
'and it finishes the roll without listing a folder twice',
|
||
carried === 3 && again.CheckRoll === 1 && again['2026'] === 1 && again.Empty === 1 && again['04'] === 1 && (await position()) === null,
|
||
`${carried} tiles, folders listed ${JSON.stringify(again)}`
|
||
);
|
||
|
||
await browser.close();
|
||
sampleServer.close();
|
||
app.kill('SIGTERM');
|
||
console.log(failures ? `\n${failures} failed` : '\nall checks passed');
|
||
process.exit(failures ? 1 : 0);
|