962 lines
48 KiB
JavaScript
962 lines
48 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';
|
||
// Where the served JPEG claims to have been shot. No sample frame carries a GPS
|
||
// tag, and a file that has one is the only way to check the place under the
|
||
// preview, so the check writes one: an APP1 segment holding a GPS IFD and
|
||
// nothing else, spliced in right after the frame's own SOI.
|
||
//
|
||
// It is deliberately the first APP1: a JPEG carries one EXIF segment, and the
|
||
// catalogue reads the first, so spending it on the coordinates is what makes
|
||
// this a file that has a place — the camera's own numbers go with the segment
|
||
// it replaced, which is why the RAW below is the one that shows a spec line.
|
||
const GPS = {
|
||
lat: [[16, 1], [3, 1], [1584, 100]], // 16° 3' 15.84"
|
||
lng: [[108, 1], [12, 1], [792, 100]], // 108° 12' 7.92"
|
||
text: '16.0544, 108.2022',
|
||
};
|
||
function withGps(jpg) {
|
||
const tiff = Buffer.alloc(128);
|
||
tiff.write('II', 0, 'latin1');
|
||
tiff.writeUInt16LE(42, 2);
|
||
tiff.writeUInt32LE(8, 4); // IFD0
|
||
const ifd = (at, entries) => {
|
||
tiff.writeUInt16LE(entries.length, at);
|
||
entries.forEach(([tag, type, count, value], i) => {
|
||
const e = at + 2 + i * 12;
|
||
tiff.writeUInt16LE(tag, e);
|
||
tiff.writeUInt16LE(type, e + 2);
|
||
tiff.writeUInt32LE(count, e + 4);
|
||
tiff.writeUInt32LE(value, e + 8);
|
||
});
|
||
};
|
||
ifd(8, [[0x8825, 4, 1, 26]]); // GPSInfoIFDPointer
|
||
ifd(26, [
|
||
[0x0001, 2, 2, 0x4e], // GPSLatitudeRef "N"
|
||
[0x0002, 5, 3, 80], // GPSLatitude
|
||
[0x0003, 2, 2, 0x45], // GPSLongitudeRef "E"
|
||
[0x0004, 5, 3, 104], // GPSLongitude
|
||
]);
|
||
for (const [at, parts] of [[80, GPS.lat], [104, GPS.lng]])
|
||
parts.forEach(([n, d], i) => {
|
||
tiff.writeUInt32LE(n, at + i * 8);
|
||
tiff.writeUInt32LE(d, at + i * 8 + 4);
|
||
});
|
||
const app1 = Buffer.concat([Buffer.from([0xff, 0xe1, 0, 0]), Buffer.from('Exif\0\0', 'latin1'), tiff]);
|
||
app1.writeUInt16BE(app1.length - 2, 2);
|
||
return Buffer.concat([jpg.subarray(0, 2), app1, jpg.subarray(2)]);
|
||
}
|
||
|
||
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));
|
||
const served = name === JPG ? withGps(bytes) : bytes;
|
||
// 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': served.length,
|
||
'Access-Control-Allow-Origin': '*',
|
||
});
|
||
res.end(served);
|
||
});
|
||
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. Whole reads and heads are
|
||
// counted apart, because they are two different things: a RAW read whole is
|
||
// the scan developing a frame, while a head is what gets handed to a parser —
|
||
// the catalogue's own date read, or the numbers the preview prints. 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 heads = (window.__heads = {});
|
||
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) {
|
||
const counter = off.has(this) ? heads : reads;
|
||
counter[name] = (counter[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. The two frames are
|
||
// written years apart, in different parts of the day — the JPEG in the
|
||
// afternoon of 15 Feb 2016 and the RAW at seven in the morning of
|
||
// 1 Jan 2026 — because the shelf's own filters are read off the
|
||
// shutter time: a year, and an hour range. Both are UTC and read back
|
||
// on this machine's clock, which is UTC+7.
|
||
lastModified: name.endsWith('.JPG') ? Date.UTC(2016, 1, 15, 9, 30) : 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,
|
||
// The score the reader gave it, 0 when they have given none.
|
||
star: photo.star ?? 0,
|
||
});
|
||
}
|
||
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 opens with every folder shut — the column is the folder that was
|
||
// picked and nothing under it until it is asked for — and one click on a row
|
||
// draws its subtree, the click after that folds it again.
|
||
const shutRows = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length);
|
||
const shutAria = await page.getAttribute('[data-key="lib-node-CheckRoll"]', 'aria-expanded');
|
||
step(
|
||
'the tree opens with the folders folded away',
|
||
shutRows === 1 && shutAria === 'false',
|
||
`${shutRows} rows, aria-expanded=${shutAria}`
|
||
);
|
||
|
||
// 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. One row's click
|
||
// draws that row's children and no more, so the walk goes down a row at a time.
|
||
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 oneDeep = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length);
|
||
step('a row draws its own children, not the whole branch', oneDeep === 3, `${oneDeep} rows`);
|
||
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 nodes = await page.$$eval('[data-key^="lib-node-"]', (els) =>
|
||
els.map((e) => [e.dataset.key, e.querySelector('.adm-album-count')?.textContent])
|
||
);
|
||
step(
|
||
'and the row below it 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)
|
||
);
|
||
|
||
// The same click again folds its subtree, 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, the deep row still open');
|
||
|
||
// 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.
|
||
const beforeSwitch = await page.$$eval('[data-key^="lib-node-"]', (els) => els.length);
|
||
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 === beforeSwitch && /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 column's own control: a right click on the row of a
|
||
// folder picked at the top level folds the whole tree — every row shut, the
|
||
// top-level one included, which is the shape the column opens in — and the frames
|
||
// in the strip are the strip's business and do not move with the rows. The claim
|
||
// is what ONE click does to every row, so the item is asked for with the tree open
|
||
// at each level: the subfolder is opened here if the click before it shut it.
|
||
for (const key of ['lib-node-CheckRoll', 'lib-node-CheckRoll/2026']) {
|
||
if ((await page.getAttribute(`[data-key="${key}"]`, 'aria-expanded')) !== 'false') continue;
|
||
await page.click(`[data-key="${key}"]`);
|
||
await page.waitForFunction(() => document.querySelectorAll('[data-key^="lib-node-"]').length >= 3, null, {
|
||
timeout: 10_000,
|
||
polling: 50,
|
||
});
|
||
}
|
||
await page.waitForSelector('[data-key="lib-node-CheckRoll/2026/04"]', { timeout: 10_000 });
|
||
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 === 1, 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 every row, leaving the frames where they were',
|
||
deepRows === 4 && flatTiles === deepTiles && ended.join(' ') === 'lib-node-CheckRoll:false',
|
||
`${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, the folder it was left on, and the rows that were drawn open
|
||
// — is what the next visit opens with. Folding the tree left every row shut, so
|
||
// the visit that is left behind is a walk back down it: the row is opened (which
|
||
// is also the row that marks the screen) and the subfolder under it is opened the
|
||
// same way, and both are left that way.
|
||
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,
|
||
});
|
||
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 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);
|
||
const reopenedRows = await page.$$eval('[data-key^="lib-node-"]', (els) =>
|
||
els.map((e) => `${e.dataset.key}:${e.getAttribute('aria-expanded') ?? '-'}`)
|
||
);
|
||
step('the column drags and is the same width next time', dragged >= 160 && reopened === dragged, `${dragged}px, then ${reopened}px`);
|
||
step(
|
||
'the tree reopens with the rows that were left open',
|
||
reopenedRows.length === 4 &&
|
||
reopenedRows.join(' ') ===
|
||
'lib-node-CheckRoll:true lib-node-CheckRoll/2026:true lib-node-CheckRoll/2026/04:- lib-node-CheckRoll/Empty:-',
|
||
reopenedRows.join(' ')
|
||
);
|
||
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);
|
||
|
||
// The frame's own file says two things under the picture: where it was shot and
|
||
// the numbers the camera recorded. The JPEG off this server is the one with a
|
||
// GPS tag on it, so it is the one that gets a place — and nothing here names it,
|
||
// the page has no geocoder behind it, so the coordinates stand as they were read.
|
||
await page.waitForSelector('[data-key="lib-stage-place"]', { timeout: 60_000, state: 'attached' });
|
||
const place = await page.locator('[data-key="lib-stage-place"]').innerText();
|
||
step('the frame says where it was shot, off its own EXIF', place.trim() === GPS.text, place.trim());
|
||
|
||
// 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}"]`);
|
||
// The RAW is the frame that shows a spec line: a JPEG carries one EXIF segment
|
||
// and this server spends the stand-in's on the coordinates. Nothing is invented
|
||
// for the numbers a file does not carry, so only what the camera wrote is here —
|
||
// no ISO and no frame size, which is what this RW2 answers.
|
||
await page.waitForSelector('[data-key="lib-stage-specs"]', { timeout: 60_000, state: 'attached' });
|
||
const specs = (await page.locator('[data-key="lib-stage-specs"]').innerText()).trim();
|
||
step('and prints the numbers the camera recorded', specs === '26.4mm · f/2.8 · 1/320s', specs);
|
||
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}`);
|
||
|
||
// The reader's own score on a frame, and the wall read through it. The score is
|
||
// given from the row under the picture and nothing else has to be opened or
|
||
// visited to see it; the wall is then narrowed by that score, by the year the
|
||
// shutter fired and by the hours it fired in. The check's frames were written so
|
||
// that all three answer something: the JPEG in the afternoon of 15 Feb 2016, the
|
||
// RAW at seven in the morning of 1 Jan 2026.
|
||
await page.setViewportSize({ width: 1280, height: 720 });
|
||
await page.click(`[data-key="lib-thumb-${jpgId}"]`);
|
||
await page.click('[data-key="lib-star-4"]');
|
||
const starred = (await catalogue()).thumbs.find((t) => t.id === jpgId)?.star;
|
||
const lit = await page.$eval('[data-key="lib-star-4"]', (e) => e.getAttribute('aria-pressed'));
|
||
step('a frame is given a score from the row under it', starred === 4 && lit === 'true', `catalogue ${starred}, 4th star ${lit}`);
|
||
|
||
await page.click('[data-key="lib-view-grid"]');
|
||
await page.waitForSelector('[data-key^="lib-photo-"]', { timeout: 30_000 });
|
||
// Each filter is set on its own and put back, so what the wall holds is read
|
||
// after one choice and never after two.
|
||
const wall = () => page.$$eval('[data-key^="lib-photo-"]', (els) => els.length);
|
||
const setFilter = async (key, value) => {
|
||
await page.selectOption(`[data-key="${key}"]`, value);
|
||
await page.waitForTimeout(150);
|
||
};
|
||
await setFilter('lib-filter-star', '4');
|
||
const fourUp = await wall();
|
||
await setFilter('lib-filter-star', '5');
|
||
const fiveUp = await wall();
|
||
await setFilter('lib-filter-star', '0');
|
||
step('the wall narrows to the frames that were given the stars', fourUp === 1 && fiveUp === 0 && (await wall()) === 3, `4★ ${fourUp}, 5★ ${fiveUp}, any 3`);
|
||
|
||
await setFilter('lib-filter-year', '2016');
|
||
const in2016 = await wall();
|
||
await setFilter('lib-filter-year', 'all');
|
||
step('and to the year the shutter fired in', in2016 === 2, `2016 → ${in2016} tiles of 3`);
|
||
|
||
await setFilter('lib-filter-from', '16');
|
||
await setFilter('lib-filter-to', '18');
|
||
const afternoon = await wall();
|
||
await setFilter('lib-filter-from', 'any');
|
||
await setFilter('lib-filter-to', 'any');
|
||
step('and to the hours it fired in', afternoon === 2, `16:00–18:00 → ${afternoon} tiles of 3`);
|
||
|
||
// The order the wall is read in, and back to the shelf: what is left behind is
|
||
// the view the checks below read, so the filters go back to holding everything.
|
||
await setFilter('lib-sort', 'star');
|
||
const ordered = await page.$$eval('[data-key^="lib-photo-"]', (els) => els.map((e) => e.dataset.key));
|
||
step(
|
||
'the wall reads in the order that was asked for',
|
||
ordered[0] === `lib-photo-${jpgId}`,
|
||
`${ordered.map((k) => k.replace('lib-photo-', '').split('/').pop()).join(', ')}`
|
||
);
|
||
await setFilter('lib-sort', 'newest');
|
||
await page.click('[data-key="lib-view-preview"]');
|
||
await page.waitForSelector(thumbs, { timeout: 30_000 });
|
||
|
||
// 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(() => ({ whole: window.__reads ?? {}, heads: window.__heads ?? {} }));
|
||
step(
|
||
'an interrupted reading goes on by itself, over the frames it still holds',
|
||
restored === 3 && !read.whole[RAW] && read.heads[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 origin 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.removeItem('recipescam.library.walk.CheckRoll');
|
||
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 origin 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(localStorage.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);
|