// 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. // // What it holds down is the two faults that read the same on the screen and have // nothing to do with each other: // // 1. The catalogue is a folder the visitor picked, and the browser keeps the // handle across a reload while dropping the permission on it. Only a gesture // can hand it back, so the click on the folder is where it is asked for — // both for the folder's own handle and for the handle a row of an older build // carries. Without the ask every frame of a folder read yesterday answers // "moved or deleted" while the file sits untouched on the disk. // 2. Two builds behind one domain — a test server and a release server — open // the one database the origin owns. A build that names a version older than // the one on the disk is answered `VersionError` and every read falls back on // an empty column, with no note and nothing to read off the screen. // // npm run build && node scripts/library-check.mjs // PLAYWRIGHT_CORE= node scripts/library-check.mjs // CHROME= PLAYWRIGHT_CORE=... node scripts/library-check.mjs // BASE=http://127.0.0.1:8090 node scripts/library-check.mjs # a server already up // // 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. Only Chromium can hand a // page a folder, and only a person can answer the picker: the check stands in for // both, serving two real frames off a local server and answering // `showDirectoryPicker` with a folder made of them. 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'; const DB = 'recipescam-library'; const STORES = ['folders', 'photos', 'thumbs', 'edits', 'dirs']; 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': '*', 'Cross-Origin-Resource-Policy': 'cross-origin', }); res.end(bytes); }); await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); // A server already up, when one is named; otherwise the build in `dist/`. let app = null; const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`; if (!process.env.BASE) { app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { cwd: FRONTEND, stdio: 'ignore', }); 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'], }); // A folder of one frame, handed over the way the browser hands one over: the // handle is kept across a reload, its permission is not, and only a gesture can // hand the permission back. `check.allow` stands in for the gesture, since a run // has no hand; `check.prompt` is the reload that dropped the permission. const INIT = ({ origin, names, folder }) => { const asked = () => localStorage.getItem('check.prompt') === '1'; const granted = () => localStorage.getItem('check.granted') === '1'; const bump = (key) => localStorage.setItem(key, String(Number(localStorage.getItem(key) ?? 0) + 1)); const perm = () => (!asked() || granted() ? 'granted' : 'prompt'); const refuse = (what) => { bump('check.refused'); localStorage.setItem(`check.refused.${what}`, '1'); throw new DOMException('no permission', 'NotAllowedError'); }; const bytes = new Map(); const load = async (n) => { if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer())); return bytes.get(n); }; // The browser's answer to an ask for permission: granted with nothing to ask, // refused while the screen is drawing itself, and granted off the reader's own // gesture. const askPerm = async () => { bump('check.requested'); if (!asked()) return 'granted'; if (localStorage.getItem('check.allow') !== '1') return 'denied'; bump('check.answered'); localStorage.setItem('check.granted', '1'); return 'granted'; }; const fileHandle = (name) => { const h = { kind: 'file', name, __fake: true }; Object.defineProperties(h, { getFile: { value: async () => { if (perm() !== 'granted') refuse('getFile'); return new File([await load(name)], name, { type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1), }); }, }, // A real file handle answers these as well, and the row a catalogue of an // older build wrote is opened off its own handle. queryPermission: { value: async () => perm() }, requestPermission: { value: askPerm }, }); return h; }; const dirHandle = (label, node) => { const h = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(h, { values: { value: () => [ ...(node.files ?? []).map(fileHandle), ...Object.entries(node.dirs ?? {}).map(([n, k]) => dirHandle(n, k)), ][Symbol.iterator](), }, queryPermission: { value: async () => perm() }, requestPermission: { value: askPerm }, getDirectoryHandle: { value: async (name) => { const k = node.dirs?.[name]; if (!k) throw new DOMException('no folder', 'NotFoundError'); return dirHandle(name, k); }, }, getFileHandle: { value: async (name) => { if (!(node.files ?? []).includes(name)) throw new DOMException('no frame', 'NotFoundError'); if (perm() !== 'granted') refuse('getFileHandle'); return fileHandle(name); }, }, }); return h; }; const root = () => dirHandle(folder, { files: names, dirs: {} }); window.showDirectoryPicker = async () => root(); // A handle out of IndexedDB is a plain object with no methods; put them back. 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 m of ['get', 'getAll']) { const original = IDBObjectStore.prototype[m]; IDBObjectStore.prototype[m] = function (...a) { const req = original.apply(this, a); const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); Object.defineProperty(req, 'result', { configurable: true, get() { const v = d.get.call(this); return Array.isArray(v) ? v.map(revive) : revive(v); }, }); return req; }; } }; // The library is signed-in ground and neither `vite preview` nor a bare container // has an API behind it, so a run is a guest the app bounces back to the landing // page before the column is ever drawn. The one call that decides which it is is // answered here; the rest of `/api` fails the way a serverless build's does. const open = async () => { const context = await browser.newContext(); await context.route('**/api/auth/me', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } }), }) ); await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [JPG], folder: 'CheckRoll' }); const page = await context.newPage(); const errors = []; const logs = []; page.on('pageerror', (e) => errors.push(e.message)); page.on('console', (m) => m.type() === 'error' && logs.push(m.text())); return { context, page, errors, logs }; }; const tiles = '[data-key^="lib-thumb-"]'; const status = (page) => page .$eval('[data-key="lib-status"]', (e) => e.textContent.trim()) .catch(() => ''); // The handover into the studio is an address; the studio reads it and clears it, // so a run that opened a frame lands on `/app`. const inStudio = (page) => /^\/app(\?|$)/.test(new URL(page.url()).pathname + new URL(page.url()).search); const openFrame = async (page) => { await page.evaluate(() => localStorage.setItem('check.allow', '1')); await page.click('[data-key="lib-open"]'); await page.waitForTimeout(3000); }; // --- 1. a folder whose permission the reload dropped ------------------------- { const { context, page, errors } = await open(); await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); await page.waitForSelector(tiles, { timeout: 120_000 }); step('the roll is indexed', true); // The tab closed: the browser kept the handle, not the permission. await page.evaluate(() => { localStorage.setItem('check.prompt', '1'); for (const k of ['check.granted', 'check.requested', 'check.refused', 'check.allow', 'check.answered']) localStorage.removeItem(k); }); await page.reload(); await page.waitForSelector(tiles, { timeout: 60_000 }); const noOneToAnswer = await page.evaluate(() => localStorage.getItem('check.granted')); const startupAsks = await page.evaluate(() => Number(localStorage.getItem('check.requested') ?? 0)); const before = errors.length; await openFrame(page); const answered = Number(await page.evaluate(() => localStorage.getItem('check.answered') ?? 0)); const asked = Number(await page.evaluate(() => localStorage.getItem('check.requested') ?? 0)); const granted = await page.evaluate(() => localStorage.getItem('check.granted')); const note = await status(page); step('the folder really has no permission while the screen draws itself', noOneToAnswer === null, `start-up asked x${startupAsks}, granted=${noOneToAnswer}`); step('the click asks the browser for the folder back', answered > 0 && granted === '1', `answered x${answered} of x${asked}`); step('the frame opens once the click has handed the permission back', inStudio(page), page.url()); step( 'no "moved or deleted" note for a frame that is still on the disk', inStudio(page) && !/moved or deleted|di chuyển|lib\.missing/.test(note), note || '(no note)' ); step('no uncaught error while opening', errors.length === before, errors.slice(before).join(' | ')); await context.close(); } // --- 2. a catalogue a newer build wrote ------------------------------------- { const { context, page, errors, logs } = await open(); await page.goto(`${base}/`); await page.evaluate( ({ DB, STORES }) => new Promise((resolve, reject) => { const req = indexedDB.open(DB, 99); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(STORES[0])) db.createObjectStore(STORES[0], { keyPath: 'name' }); if (!db.objectStoreNames.contains(STORES[1])) { const s = db.createObjectStore(STORES[1], { keyPath: 'id' }); s.createIndex('folder', 'folder'); s.createIndex('taken', 'taken'); } if (!db.objectStoreNames.contains(STORES[2])) db.createObjectStore(STORES[2], { keyPath: 'id' }); if (!db.objectStoreNames.contains(STORES[3])) db.createObjectStore(STORES[3], { keyPath: 'photoId' }); if (!db.objectStoreNames.contains(STORES[4])) db.createObjectStore(STORES[4], { keyPath: 'folder' }); }; req.onsuccess = () => { req.result.close(); resolve(); }; req.onerror = () => reject(req.error); }), { DB, STORES } ); step('a catalogue stands at version 99', true); const before = errors.length; await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); await page.waitForSelector(tiles, { timeout: 120_000 }); step('the older build reads and writes the newer catalogue', true); const version = await page.evaluate( async (DB) => (await indexedDB.databases()).find((d) => d.name === DB)?.version, DB ); step('it was taken as it stood, not upgraded', version === 99, `version ${version}`); step('no uncaught error', errors.length === before, errors.slice(before).join(' | ')); step( 'no "the database is missing"', !logs.some((l) => l.includes('the database is missing')), logs.join(' | ') ); await context.close(); } // --- 3. a frame out of a catalogue an older build wrote --------------------- // That build kept a handle on every row, and a handle out of IndexedDB comes back // without its permission. The shortcut that opens a frame off its own row has to // ask for it back the same way the folder's does, or the frame answers "nothing // at" while it sits untouched on the disk. { const { context, page, errors } = await open(); await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); await page.waitForSelector(tiles, { timeout: 120_000 }); const legacy = await page.evaluate( (name) => new Promise((resolve, reject) => { const req = indexedDB.open(name); req.onsuccess = () => { const db = req.result; const tx = db.transaction('photos', 'readwrite'); const store = tx.objectStore('photos'); const all = store.getAll(); all.onsuccess = () => { const rows = all.result; // A record, not a live handle: the browser hands it back with its // methods and with no permission. for (const row of rows) store.put({ ...row, handle: { kind: 'file', name: row.name, __fake: true } }); resolve(rows.map((r) => r.id)); }; all.onerror = () => reject(all.error); }; req.onerror = () => reject(req.error); }), DB ); step('the catalogue keeps a handle on every row, as the older build wrote it', legacy.length === 1, legacy.join()); await page.evaluate(() => { localStorage.setItem('check.prompt', '1'); for (const k of ['check.granted', 'check.requested', 'check.allow', 'check.answered']) localStorage.removeItem(k); }); await page.reload(); await page.waitForSelector(tiles, { timeout: 60_000 }); const before = errors.length; await openFrame(page); const note = await status(page); step( 'a frame off a legacy row opens instead of reading "nothing at"', inStudio(page) && !/nothing at|lib\.missing/.test(note), note || page.url() ); step('no uncaught error', errors.length === before, errors.slice(before).join(' | ')); await context.close(); } await browser.close(); sampleServer.close(); app?.kill('SIGTERM'); console.log(failures ? `\n${failures} FAILED` : '\nall ok'); process.exit(failures ? 1 : 0);