// Where a reading had got to is written down for the whole origin, not for the // tab. The tab that reads a roll for minutes is often not the one that finishes // it: the app is closed and the installed one opens, or the tab is thrown away // and the reader comes back. The position used to live in sessionStorage, which // is the tab's own, so the second context found nothing, walked the roll from the // top and listed every folder again — the frame counter starting over while the // frames it already held were read again only to be skipped. // // Two pages of one browser context are two contexts of one origin: the first // picks the stand-in roll and is closed mid-walk, the second opens and takes the // reading up. What it costs is what says whether it did — a reading that took the // position up lists nothing, since the folders behind it came back with it, where // one that started at the top lists the whole roll a second time. // // npm run build && node scripts/walk-share-check.mjs // PLAYWRIGHT_CORE= node scripts/walk-share-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 { fileURLToPath } from 'node:url'; const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); const PORT = Number(process.env.APP_PORT ?? 4197); 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); } // `--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(PORT), '--strictPort', '--host', '127.0.0.1'], { cwd: FRONTEND, stdio: 'ignore', }); const base = `http://127.0.0.1:${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'); console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); process.exit(1); } // Dated folders of five frames each: a roll large enough that the reading is // still going when the first page is taken away, and small enough to finish. const DIRS = {}; for (let i = 0; i < 40; i++) { DIRS[`d${String(i).padStart(2, '0')}`] = { files: [1, 2, 3, 4, 5].map((n) => `F${i}_${n}.JPG`), t: 1_700_000_000_000 }; } 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 picker stands in for the person: a folder of handles over frames made in // the page (a beat apiece, so the reading can be caught in the middle of the // roll), and the same handles put back on the rows IndexedDB hands out — a cloned // handle is a plain object with no `getFile` on it. await context.addInitScript( ({ dirs }) => { let bytes = null; const jpeg = () => (bytes ??= new Promise((resolve) => { const c = document.createElement('canvas'); c.width = 24; c.height = 16; const g = c.getContext('2d'); g.fillStyle = '#b3412a'; g.fillRect(0, 0, 24, 16); c.toBlob(async (b) => resolve(new Uint8Array(await b.arrayBuffer())), 'image/jpeg', 0.8); })); const fileHandle = (name, t) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { value: async () => { await new Promise((r) => setTimeout(r, 120)); return new File([await jpeg()], name, { type: 'image/jpeg', lastModified: t }); }, }); return handle; }; const dirHandle = (label, node) => { const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => { // Every listing a reading asks for, counted for the whole origin: a // reading that starts over lists the same folder a second time, and // that second listing is the whole of what the position saves. localStorage.setItem('walkcheck.lists', String(Number(localStorage.getItem('walkcheck.lists') ?? 0) + 1)); return [ ...(node.files ?? []).map((f) => fileHandle(f, node.t)), ...Object.entries(node.dirs ?? {}).map(([name, kid]) => dirHandle(name, kid)), ][Symbol.iterator](); }, }, queryPermission: { value: async () => 'granted' }, requestPermission: { value: async () => 'granted' }, 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, node.t); }, }, }); return handle; }; const root = () => dirHandle('BigRoll', { files: [], dirs }); 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, 1_700_000_000_000); } 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; }; } }, { dirs: DIRS } ); const progress = (p) => p.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => ''); const at = (text) => { const m = /(\d+)\/(\d+)/.exec(text); return m ? { n: Number(m[1]), t: Number(m[2]) } : null; }; const listed = (p) => p.evaluate(() => Number(localStorage.getItem('walkcheck.lists') ?? 0)); const held = (p) => p.evaluate(() => { const raw = localStorage.getItem('recipescam.library.walk.BigRoll'); if (!raw) return null; const s = JSON.parse(raw); return { done: s.progress?.done, walked: s.walked?.length, frames: s.frames?.length }; }); // --- the first context: it starts the reading and is taken away ------------- const first = await context.newPage(); await first.goto(`${base}/library`); await first.evaluate(() => { localStorage.removeItem('walkcheck.lists'); localStorage.removeItem('recipescam.library.walk.BigRoll'); sessionStorage.clear(); }); await first.click('[data-key="lib-add"]'); let stopped = null; for (let i = 0; i < 600; i++) { const now = at(await progress(first)); if (now && now.n >= 40 && now.t >= 200) { stopped = now; break; } await first.waitForTimeout(50); } const wrote = await held(first); const listsA = await listed(first); await first.close(); if (!stopped || !wrote) { await browser.close(); app.kill('SIGTERM'); console.log(`FAIL the first reading never got going — ${stopped ? `${stopped.n}/${stopped.t}` : 'no counter'}, position ${JSON.stringify(wrote)}`); process.exit(1); } // --- the next context: it opens the app and reads where it starts ----------- const second = await context.newPage(); await second.goto(`${base}/library`); let opened = null; for (let i = 0; i < 1200; i++) { const now = at(await progress(second)); if (now) { opened = now; break; } await second.waitForTimeout(25); } // Let it work for a moment, then look at what it was: a reading that took the // position up has nothing to walk — the queue and the folders behind it came // back with it — where one that started at the top lists the whole roll again. await second.waitForTimeout(2500); const listsB = await listed(second); const session = await second.evaluate(() => sessionStorage.getItem('recipescam.library.walk.BigRoll') !== null); await browser.close(); app.kill('SIGTERM'); const ok = opened !== null && listsB === listsA && !session; console.log(`${ok ? 'ok ' : 'FAIL'} walk share — a reading another context left behind carries on`); console.log(` first context stopped at ${stopped.n}/${stopped.t}, ${listsA} folders listed, wrote ${JSON.stringify(wrote)}`); console.log(` next context opened at ${opened ? `${opened.n}/${opened.t}` : 'nothing — it never started'}, ${listsB} folders listed, position under the tab's own key=${session}`); process.exit(ok ? 0 : 1);