// 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; } 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)); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': bytes.length, 'Access-Control-Allow-Origin': '*', }); res.end(bytes); }); await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); // `--host 127.0.0.1`: without it vite's preview binds the name `localhost`, which // on a dual-stack box can be ::1 alone and every 127.0.0.1 fetch below fails. const app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { cwd: FRONTEND, stdio: 'ignore', }); const base = `http://127.0.0.1:${APP_PORT}`; let up = false; for (let i = 0; i < 60 && !up; i++) { up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); if (!up) await new Promise((r) => setTimeout(r, 500)); } if (!up) { app.kill('SIGTERM'); sampleServer.close(); console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); process.exit(1); } // --- the browser ------------------------------------------------------------ const browser = await playwright.chromium.launch({ // Playwright's own Chromium when it has one; CHROME points at any other. executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], }); const context = await browser.newContext(); // The folder the picker would have returned, plus the revival of its handles on // the way back out of IndexedDB. await context.addInitScript( ({ origin, names, subs, folder }) => { const bytes = new Map(); const load = async (name) => { if (!bytes.has(name)) bytes.set(name, fetch(`${origin}/${name}`).then((r) => r.arrayBuffer())); return bytes.get(name); }; const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { value: async () => new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream' }), }); return handle; }; // The picked folder holds `names` at its top level and the subfolders in // `subs` — a roll really is a folder of dated directories, which is what the // tree column walks. const dirHandle = (label, files, dirs) => { const handle = { kind: 'directory', name: label }; Object.defineProperties(handle, { values: { value: () => [...files.map(fileHandle), ...Object.entries(dirs).map(([name, kids]) => dirHandle(name, kids, {}))][ Symbol.iterator ](), }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, }); return handle; }; const root = () => dirHandle(folder, names, 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], subs: { 2026: [RAW] }, 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"]'); const thumbs = '[data-key^="lib-thumb-"]'; await page.waitForSelector(thumbs, { timeout: 120_000 }); await page.waitForFunction((sel) => document.querySelectorAll(sel).length === 2, thumbs, { timeout: 120_000 }); step('both frames indexed as thumbnails', true, `2 tiles from ${JPG} + ${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( 'tree lists the folder and its subfolder', nodes.length === 2 && nodes[0][0] === 'lib-node-CheckRoll' && nodes[1][0] === 'lib-node-CheckRoll/2026', JSON.stringify(nodes) ); // 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 === 1, 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/.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 === 1, JSON.stringify(cards)); await page.click('[data-key="lib-view-preview"]'); await page.waitForSelector(thumbs, { timeout: 30_000 }); // The toolbar carries the screen's one button and the two view icons on a single // row; a folder's own rescan and remove exist only once it has been right-clicked. const bar = await page.$eval('.lib-bar', (el) => ({ rows: new Set( [...el.children].map((c) => { const box = c.getBoundingClientRect(); return Math.round(box.top + box.height / 2); }) ).size, icons: [...el.querySelectorAll('[data-key^="lib-view-"]')].every((b) => b.querySelector('svg')), })); step('one toolbar row holding the two view icons', bar.rows === 1 && bar.icons, JSON.stringify(bar)); step('no rescan/remove button in the pane', (await page.$$('[data-key^="lib-rescan-"], [data-key^="lib-drop-"]')).length === 0); await page.click('[data-key="lib-node-CheckRoll/2026"]', { button: 'right' }); await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); const menu = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); step('a right click on a folder opens its menu', menu.join(',') === 'lib-rename-CheckRoll,lib-rescan-CheckRoll,lib-drop-CheckRoll', JSON.stringify(menu)); await page.keyboard.press('Escape'); step('escape closes the menu', (await page.$('[data-key="lib-menu"]')) === null); // The column is names and counts, nothing else: the count rides on the row. const column = await page.$eval('[data-key="lib-tree"]', (el) => Math.round(el.getBoundingClientRect().width)); step( 'no FOLDERS heading, the count on the folder row', (await page.$$('.adm-row-title')).length === 0 && (await page.$eval('[data-key="lib-node-CheckRoll"] .adm-album-count', (e) => e.textContent)) === '2', `${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 empty part of the column is where one more folder comes from. await page.click('[data-key="lib-tree"]', { button: 'right', position: { x: 10, y: 200 } }); await page.waitForSelector('[data-key="lib-menu"]', { timeout: 10_000 }); const blank = await page.$$eval('[data-key="lib-menu"] button', (els) => els.map((b) => b.dataset.key)); step('a right click on the empty column offers a folder', blank.join(',') === 'lib-menu-add', JSON.stringify(blank)); await page.keyboard.press('Escape'); step('escape closes the folder menu too', (await page.$('[data-key="lib-menu"]')) === null); // One tap on a thumbnail raises the frame, and the button under it hands it to // the studio. The RAW is the harder half — it has to be read back off the // (stand-in) handle and developed — so it is the one that gets opened. const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id; await page.click(`[data-key="lib-thumb-${rawId}"]`); await page.click('[data-key="lib-open"]'); await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 }); await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 }); step('studio developed the frame from its handle', true, 'stage no longer on the drop zone'); // The handover is an address, not a state: the studio puts the frame into its // own store and drops the query, so a reload reopens it like any other photo. step('the address was handed back', !page.url().includes('lib='), `url=${page.url()}`); const editsIn = () => page.evaluate(async () => { const db = await new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); return await new Promise((resolve) => { const req = db.transaction('edits').objectStore('edits').getAll(); req.onsuccess = () => resolve(req.result); }); }); // The recipe is written 600ms after the last change (a slider drag fires it per // frame), so the row arrives a beat after the frame does. let filed = []; for (let i = 0; i < 40 && !filed.length; i++) { filed = await editsIn(); if (!filed.length) await new Promise((r) => setTimeout(r, 250)); } step('the look was filed back against the frame', filed.length === 1, JSON.stringify(filed)); await page.goto(`${base}/library`); await page.waitForSelector(`[data-key="lib-thumb-${rawId}"]`); await page.click(`[data-key="lib-thumb-${rawId}"]`); const meta = await page.locator('[data-key="lib-stage-meta"]').innerText(); step('the frame says it was edited', /đã chỉnh|edited/.test(meta), meta.replace(/\n/g, ' · ')); await browser.close(); sampleServer.close(); app.kill('SIGTERM'); console.log(failures ? `\n${failures} failed` : '\nall checks passed'); process.exit(failures ? 1 : 0);