// The catalogue's own copy, end to end: a roll of two frames is read, the // catalogue is written into a folder the visitor picked, the browser's storage is // taken away from under it, and the catalogue is read back out — with the frames // never read a second time. // // The folder of frames is a stand-in served off a local server, because the check // cannot hand a real directory to a page. The backup folder is not: the picker // answers with a directory out of the origin's own file system (OPFS), which is a // real `FileSystemDirectoryHandle` with a real `createWritable`, so what is // written is written by the code under test through the API it will use on a // visitor's disk — and what the check then reads back is the bytes on it. // // What the run claims, in order: // * the catalogue file holds the rows and neither of the two things a file // cannot hold — a handle and a tile; // * the tiles are written beside it, one per frame, under `thumbs/`; // * a second run writes no tile at all; // * a catalogue with nothing in it gets every row back, tiles included, and the // frames themselves are never read from the disk again — not by the restore, // which takes the tiles off the backup folder, and not by the reading that // follows it, which matches each frame on its size and its write time. // // npm run build && node scripts/backup-check.mjs // PLAYWRIGHT_CORE= node scripts/backup-check.mjs // // Playwright is not a dependency of this package, so a run without it says SKIP // and exits 0. APP_PORT says where the preview goes, CHROME says which browser. 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 ?? 4198); const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4321); // Two frames of one folder, both read off the sample roll. const FILES = ['P1010256.JPG', 'DSCF1701.JPG']; const FOLDER = 'TestRoll'; const BACKUP = 'BackupRoll'; 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 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 (!FILES.includes(name)) { res.writeHead(404).end(); return; } const bytes = await readFile(path.join(SAMPLES, name)); res.writeHead(200, { 'Content-Type': 'image/jpeg', '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); } 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 words this screen says are read below, so the run is pinned to one of the // two dictionaries rather than to whatever the browser's own language is. await context.addInitScript(() => localStorage.setItem('rc.lang', 'en')); // The picker, twice over: the roll of frames is a stand-in — a page cannot be // handed a real directory by a test — and the backup folder is the origin's own // file system, which is a real one. Which of the two a call is for is the `id` // the code passes, the same one it passes to a visitor's browser. await context.addInitScript( ({ origin, files, folder, backup }) => { 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 frame costs is its bytes, and the bytes of a frame are read through // `arrayBuffer` — on the file, or on a slice of it, which is how a scan reads // the head of a JPEG. Only the stand-in's own files are counted: the tiles // that come back out of the backup folder are read the same way, and they are // named after the frames. window.__frameReads = 0; 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); return cut; }; const pull = Blob.prototype.arrayBuffer; Blob.prototype.arrayBuffer = function () { if ((off.get(this) ?? this).__fake) window.__frameReads++; return pull.apply(this); }; const fileHandle = (name) => { const handle = { kind: 'file', name }; Object.defineProperty(handle, 'getFile', { value: async () => { const file = new File([await load(name)], name, { type: 'image/jpeg', // One write time per frame, and the same one on every read: a fresh // `Date.now()` would make every frame look touched and hide what is // being checked — the reading after the restore has to match. lastModified: Date.UTC(2016, 1, 15, 9, 30), }); Object.defineProperty(file, '__fake', { value: true }); return file; }, }); return handle; }; const dirHandle = (label, node) => { const handle = { kind: 'directory', name: label }; Object.defineProperties(handle, { values: { value: () => [...(node.files ?? []).map(fileHandle)][Symbol.iterator]() }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, getDirectoryHandle: { value: async (name) => { throw new Error(`no folder named ${name}`); } }, getFileHandle: { value: async (name) => { if (!(node.files ?? []).includes(name)) throw new Error(`no frame named ${name}`); return fileHandle(name); }, }, }); return handle; }; const root = (label) => dirHandle(label, { files }); window.showDirectoryPicker = async (opts) => { if (opts?.id !== 'recipescam-backup') return root(folder); const opfs = await navigator.storage.getDirectory(); // A folder of the origin's own, emptied at the start of the run and named // as the picker would name it. await opfs.removeEntry(backup, { recursive: true }).catch(() => {}); const dir = await opfs.getDirectoryHandle(backup, { create: true }); // OPFS handles are always writable and say so only through the API the // picker's handles carry: the two methods are stood in where the real // handle leaves them out, so the code under test takes the same road it // would with a folder off a disk. for (const mode of ['queryPermission', 'requestPermission']) { if (typeof dir[mode] !== 'function') Object.defineProperty(dir, mode, { value: async () => 'granted' }); } return dir; }; }, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, files: FILES, folder: FOLDER, backup: BACKUP } ); const page = await context.newPage(); // The restore asks before it replaces anything, and a dismissed dialog is a // cancel — the run says yes the way a reader would. page.on('dialog', (dialog) => void dialog.accept()); const note = () => page.$eval('.adm-note', (el) => el.textContent ?? '').catch(() => ''); const waitNote = (re) => page.waitForFunction( (src) => new RegExp(src).test(document.querySelector('.adm-note')?.textContent ?? ''), re.source, { timeout: 120_000 } ); const count = () => page.$eval('[data-key="lib-count"]', (el) => el.textContent ?? ''); // The bytes of a frame actually read off the disk, counted inside the page: the // whole claim of the catalogued size and write time is this number staying put. const framesRead = () => page.evaluate(() => window.__frameReads ?? 0); // The catalogue the page itself keeps, read straight out of the browser: what the // restore puts back is a row in there, not a picture on the screen. const rows = () => page.evaluate( () => new Promise((resolve) => { const open = indexedDB.open('recipescam-library'); open.onsuccess = () => { const req = open.result.transaction('photos', 'readonly').objectStore('photos').getAll(); req.onsuccess = () => resolve( req.result.map((r) => ({ id: r.id, tile: r.thumb ? r.thumb.size : 0, handle: !!r.handle, star: r.star ?? null, })) ); req.onerror = () => resolve([]); }; open.onerror = () => resolve([]); }) ); // Everything the backup folder holds, walked the way a person looking at it in a // file manager would. const backupTree = () => page.evaluate(async (name) => { const opfs = await navigator.storage.getDirectory(); const root = await opfs.getDirectoryHandle(name); const out = []; const walk = async (dir, at) => { for await (const [child, handle] of dir.entries()) { const rel = at ? `${at}/${child}` : child; if (handle.kind === 'directory') await walk(handle, rel); else out.push({ rel, size: (await handle.getFile()).size }); } }; await walk(root, ''); return out; }, BACKUP); let code = 0; try { // --- the roll is read ------------------------------------------------------ await page.goto(`${base}/library`); await page.click('[data-key="lib-add"]'); await waitNote(/Scanned TestRoll/); const read = await rows(); step('the roll was read and both frames are in the catalogue', read.length === 2, `${read.length} rows`); step('every frame has its tile', read.every((r) => r.tile > 0)); step('the count on the bar says two', /2/.test(await count()), await count()); const firstRead = await framesRead(); step('reading a roll of two frames costs two frames', firstRead === 2, `${firstRead} reads`); // --- the catalogue is written --------------------------------------------- await page.click('[data-key="lib-backup"]'); await waitNote(/Backed up 2 frames, 2 tiles written/); const tree = await backupTree(); const cat = await page.evaluate(async (name) => { const opfs = await navigator.storage.getDirectory(); const dir = await opfs.getDirectoryHandle(name); return JSON.parse(await (await (await dir.getFileHandle('recipescam-catalogue.json')).getFile()).text()); }, BACKUP); step('the catalogue file holds both rows', cat.photos.length === 2, `${cat.photos.length}`); step( 'a row carries no handle and no tile', cat.photos.every((r) => !('handle' in r) && !('thumb' in r)), Object.keys(cat.photos[0]).join(',') ); step( 'a row carries the size and the write time of its frame', cat.photos.every((r) => r.size > 0 && r.mtime === Date.UTC(2016, 1, 15, 9, 30)) ); const tiles = tree.filter((f) => f.rel.startsWith('thumbs/')); step( 'the tiles are beside it, one file per frame', tiles.length === 2 && tiles.every((f) => f.size > 0) && tiles.every((f) => /^thumbs\/TestRoll\/[^/]+$/.test(f.rel)), tiles.map((f) => f.rel).join(' ') ); // --- a second run writes no tile ------------------------------------------ await page.click('[data-key="lib-backup"]'); await waitNote(/Backed up 2 frames, 0 tiles written/); // --- the browser's storage is taken away ---------------------------------- await page.goto('about:blank'); await page.goto(`${base}/`); const gone = await page.evaluate( () => new Promise((resolve) => { const req = indexedDB.deleteDatabase('recipescam-library'); req.onsuccess = () => resolve(true); req.onerror = req.onblocked = () => resolve(false); }) ); step('the catalogue is gone from the browser', gone === true); await page.goto(`${base}/library`); step('the library comes up empty', /^0\b/.test((await count()).trim()), await count()); step('and has read nothing', (await framesRead()) === 0); // --- and it is put back ---------------------------------------------------- await page.click('[data-key="lib-restore"]'); await waitNote(/Restored 2 frames/); const back = await rows(); step('both rows are back', back.length === 2, `${back.length} rows`); step('each row came back with its tile', back.every((r) => r.tile > 0)); step('no row came back with a handle — a handle is not a file', back.every((r) => !r.handle)); step('the restore read no frame from the disk', (await framesRead()) === 0, `${await framesRead()} reads`); step('the count on the bar says two again', /2/.test(await count()), await count()); // --- the folder is picked again, and no frame is read for it --------------- await page.click('[data-key="lib-add"]'); await waitNote(/Scanned TestRoll/); const joined = await rows(); step('the reading gave every row its handle back', joined.length === 2 && joined.every((r) => r.handle)); step('and read no frame to do it', (await framesRead()) === 0, `${await framesRead()} reads`); } catch (err) { step(`the run finished`, false, String(err).split('\n')[0]); code = 1; } console.log(failures ? `FAIL ${failures} step(s)` : 'ok the catalogue survives its browser'); await browser.close(); app.kill('SIGTERM'); sampleServer.close(); process.exit(failures || code ? 1 : 0);