// TEMPORARY — the bug this proves: reading a roll used to die the moment the // catalogue handed the visitor to the studio, because every internal link was a // full page load and the scan lived in that page's memory. Run it, then delete // it. // // node scripts/scan-nav-check.mjs 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 ?? 4186); const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4320); const DELAY = Number(process.env.SCAN_DELAY ?? 900); const JPG = 'P1010256.JPG'; const RAW = 'P1010256.RW2'; // Twelve frames: four at the top, four a folder down, four two folders down. const names = (prefix) => Array.from({ length: 4 }, (_, i) => `${prefix}${i + 1}.JPG`); const ROLL = { files: names('a'), dirs: { '2026': { files: names('b'), dirs: { '04': { files: names('c'), dirs: {} } } } }, }; const TOTAL = 12; 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'); process.exit(0); } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // One frame at a time, however many the page asks for at once: a roll that reads // itself in parallel is over before a handover can be timed against it. let turn = Promise.resolve(); const waitTurn = () => { const mine = turn.then(() => sleep(DELAY)); turn = mine; return mine; }; const sampleServer = createServer(async (req, res) => { const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); if (!/\.(jpg|rw2)$/i.test(name)) { res.writeHead(404).end(); return; } const bytes = await readFile(path.join(SAMPLES, /\.rw2$/i.test(name) ? RAW : JPG)); await waitTurn(); 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)); 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 sleep(500); } if (!up) { app.kill('SIGTERM'); sampleServer.close(); console.log(`FAIL no preview on ${base}`); process.exit(1); } const browser = await playwright.chromium.launch({ executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], }); const context = await browser.newContext(); // Documents this tab has loaded, across navigations: the count only moves when a // click threw the page away and started a new one. await context.addInitScript(() => { const n = Number(sessionStorage.getItem('docLoads') ?? '0') + 1; sessionStorage.setItem('docLoads', String(n)); }); await context.addInitScript( ({ origin, roll, 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); }; // A second scan of the same roll reads the same names again; dropping what the // stand-in already holds makes it go back to the server for each of them, so // a re-reading roll has the same minutes in it a first one does. window.__forget = () => bytes.clear(); const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { value: async () => new File([await load(name)], name, { type: 'image/jpeg' }), }); return handle; }; const dirHandle = (label, node) => { // `__fake` on a folder too: the catalogue reads its folders back out of the // store after every remount, and a handle that does not say what it is comes // back as a plain object with no `queryPermission` — a folder it cannot ask // about is a folder it offers to reconnect. const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => [ ...(node.files ?? []).map(fileHandle), ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), ][Symbol.iterator](), }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, }); return handle; }; const root = () => dirHandle(folder, roll); 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}`, roll: ROLL, folder: 'SlowRoll' } ); const page = await context.newPage(); const indexed = () => 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 new Promise((resolve, reject) => { const req = db.transaction('photos').objectStore('photos').count(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); }); await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 }); step('the scan is running', true); // The rows only reach IndexedDB in one batch at the end of the scan, so the // toolbar's own line is what says how far the reading is. const progressText = (timeout) => page .waitForFunction(() => document.querySelector('[data-key="lib-progress"]')?.textContent?.trim() || null, null, { timeout, polling: 100, }) .then((h) => h.jsonValue()) .catch(() => null); const docs = () => page.evaluate(() => Number(sessionStorage.getItem('docLoads') ?? '0')); const tiles = () => page.$$eval('[data-key^="lib-thumb-"]', (els) => els.length); // The count of documents standing when the page was last settled: every step that // follows a link or a frame asserts against this, because a link followed with no // scan in flight is *meant* to load the page again. let seen = await docs(); const noReload = async () => (await docs()) === seen; // Hand over to the studio while the roll is still being read, which is the whole // point: part of it in, the rest still to come. const midway = await progressText(15_000); step('the roll is half read when the studio is opened', !!midway, String(midway)); await page.click('[data-key="lib-studio"]'); await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 }); step('the handover stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`); step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url()); // Back to the catalogue mid-scan: the screen that started the scan is gone and // back, and the roll is still being read — the reading belongs to the tab. await page.click('[data-key="nav-library"]'); await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 }); step('coming back stays in the page too', await noReload(), `${await docs()} document load(s)`); const progress = await progressText(15_000); step('the catalogue comes back showing the scan it missed', !!progress, String(progress)); await page.waitForSelector('[data-key^="lib-node-"]', { timeout: 15_000 }); const tree = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key)); step('the tree is there, from the scan that outlived its screen', tree.includes('lib-node-SlowRoll'), JSON.stringify(tree)); // Now leave it alone in the studio and let it finish: nothing up there reads the // roll, so every frame that lands from here on landed for a screen that is gone. await page.click('[data-key="lib-studio"]'); await page.waitForSelector('[data-key="nav-library"]', { timeout: 15_000 }); let indexed_to = 0; for (let i = 0; i < 300 && indexed_to < TOTAL; i++) { indexed_to = await indexed().catch(() => indexed_to); if (indexed_to < TOTAL) await sleep(200); } step('the whole roll is read with the studio up', indexed_to === TOTAL, `${indexed_to}/${TOTAL} indexed`); step('and the studio is still the page it was', await noReload(), `${await docs()} document load(s)`); await page.click('[data-key="nav-library"]'); await page.waitForFunction( (n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n, TOTAL, { timeout: 60_000, polling: 200 } ).catch(() => {}); step('every frame ends up as a thumbnail', (await tiles()) === TOTAL, `${await tiles()} tiles`); const grown = await page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => e.dataset.key)); step('the tree ends up holding the deepest folder of the roll', grown.includes('lib-node-SlowRoll/2026/04'), JSON.stringify(grown)); // Nothing was scanning at that last click, so that one did load the page again — // which is the whole point of the guard. Everything below counts from here. seen = await docs(); // Now the reader's own way into the studio, and the noisiest one for this page: a // frame opened from a roll that is being read again. The rows are drawn from the // catalogue it already has, so this is the one route out of here that the browser // could take with no link anywhere in sight. const rows = () => page.$eval('[data-key="lib-node-SlowRoll"] .adm-album-count', (el) => Number(el.textContent)); await page.evaluate(() => window.__forget()); await page.click('[data-key="lib-node-SlowRoll"]', { 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)); const rescan = menu.find((k) => /^lib-(rescan|reconnect)-/.test(k)); step('the roll can be read again from its own menu', !!rescan, JSON.stringify(menu)); await page.click(`[data-key="${rescan}"]`); await page.waitForSelector('[data-key="lib-stop"]', { timeout: 15_000 }); await sleep(DELAY); const early = await rows(); await sleep(DELAY * 2); const later = await rows(); step('the row counts the reading as it comes, not the catalogue standing still', later !== early, `${early} → ${later} of ${TOTAL}`); step('the strip is still the catalogue the roll left behind', (await tiles()) === TOTAL, `${await tiles()} tiles`); await page.click('[data-key="lib-open"]'); await page.waitForSelector('[data-key="nav-library"]', { timeout: 30_000 }); step('opening a frame stays in the page, it does not reload it', await noReload(), `${await docs()} document load(s)`); // The studio takes the frame out of the address as soon as it has it, so the // route is what is left to say the handover arrived. step('and it lands in the studio', new URL(page.url()).pathname === '/app', page.url()); await page.click('[data-key="nav-library"]'); await page.waitForSelector('[data-key="lib-frame"]', { timeout: 15_000 }); step('and the scan it interrupted is still the scan in hand', await noReload(), `${await docs()} document load(s)`); const steady = await progressText(15_000); step('the catalogue is still reading the roll it was reading', !!steady, String(steady)); await browser.close(); app.kill('SIGTERM'); sampleServer.close(); console.log(failures ? `\n${failures} step(s) failed` : '\nall steps passed'); process.exit(failures ? 1 : 0);