// 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= 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='); 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);