// The three things the reader reported, checked in a browser: the counts beside // the folder names while a reading is running, what the column holds after the // tab comes back — every folder the walk went into, the empty ones included, with // an interrupted reading picking itself up — and a long roll's tiles, every frame // the wall draws read out of the reading that brought them. // // The app is behind an account and answers `/api` on its own origin, so the check // stands in for the API the way the container's nginx does; without it the page // is a guest and there is no folder to pick. The catalogue is read back out of // IndexedDB rather than out of the strip, which draws one node's own frames. // // npm run build && node scripts/tree-scan-check.mjs // APP_PORT=4395 SAMPLE_PORT=4396 SAMPLE_DIR= \ // PLAYWRIGHT_CORE= CHROME= \ // node scripts/tree-scan-check.mjs import { spawn } from 'node:child_process'; 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, '..', 'sample'); 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'; // What the frames of the long roll are served: a small JPEG, because a hundred // copies of an eight-megabyte one is eight hundred megabytes to prove a point // about tiles. const SMALL = 'Main.jpg'; const LONG = 60; const LONG_NAMES = Array.from({ length: LONG }, (_, i) => `B${String(i).padStart(3, '0')}.JPG`); 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); } // Every frame the page asks for, by extension: one real JPEG and one real RAW — see // the route that hands them over, below. 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'); console.log(`FAIL no preview on ${base} — run \`npm run build\` first`); process.exit(1); } const browser = await playwright.chromium.launch({ executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader'], }); const context = await browser.newContext(); // The sample frames are handed over inside the browser rather than off a socket of // its own: a page is given six sockets to a host, and the walk's sixty reads fill // all six, so the shelf's own reads would queue behind the reading they are meant // to keep up with — a queue no real disk puts in the way. await context.route(`http://127.0.0.1:${SAMPLE_PORT}/**`, async (route) => { const name = path.basename(decodeURIComponent(new URL(route.request().url()).pathname)); const file = /^B\d+\.JPG$/.test(name) ? SMALL : /\.jpe?g$/i.test(name) ? JPG : /\.rw2$/i.test(name) ? RAW : null; if (!file) return route.fulfill({ status: 404, body: '' }); return route.fulfill({ status: 200, contentType: 'application/octet-stream', headers: { 'Access-Control-Allow-Origin': '*' }, body: await readFile(path.join(SAMPLES, file)), }); }); // The catalogue is behind an account, and the header reads the account's own // folder: the API answers for both, the way the container beside the page does. await context.route('**/api/**', (route) => { const url = route.request().url(); const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) }); if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com', name: 'check' } }); if (url.includes('/photos')) return json({ photos: [] }); if (url.includes('/recipes')) return json({ recipes: [] }); return route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'no' }) }); }); await context.addInitScript( ({ origin, folder, rolls, small }) => { const bytes = new Map(); // One fetch per real file whatever the handle is called: the frames of the // long roll are one small JPEG served under sixty names. const load = async (name) => { const file = /^B\d+\.JPG$/.test(name) ? small : name; if (!bytes.has(file)) bytes.set(file, fetch(`${origin}/${file}`).then((r) => r.arrayBuffer())); return bytes.get(file); }; const fileHandle = (name) => { const handle = { kind: 'file', name, __fake: true }; Object.defineProperty(handle, 'getFile', { value: async () => { const gets = JSON.parse(sessionStorage.getItem('check.gets') ?? '{}'); gets[name] = (gets[name] ?? 0) + 1; sessionStorage.setItem('check.gets', JSON.stringify(gets)); while (localStorage.getItem('check.hold') === '1') await new Promise((r) => setTimeout(r, 25)); // A read of a frame is nearly all waiting on a real disk, and a reading // that is over in a blink is one the wall's own work cannot be watched // against. `check.slow` is that wait, in milliseconds. const slow = Number(localStorage.getItem('check.slow') ?? 0); if (slow) await new Promise((r) => setTimeout(r, slow)); return new File([await load(name)], name, { type: 'application/octet-stream', lastModified: Date.UTC(2026, 0, 1) }); }, }); return handle; }; const dirHandle = (label, node) => { const handle = { kind: 'directory', name: label, __fake: true }; Object.defineProperties(handle, { values: { value: () => { const lists = JSON.parse(sessionStorage.getItem('check.lists') ?? '{}'); lists[label] = (lists[label] ?? 0) + 1; sessionStorage.setItem('check.lists', JSON.stringify(lists)); if (node.fail && localStorage.getItem('check.broken') !== 'open') throw new Error(`${label} will not open`); return [ ...(node.files ?? []).map(fileHandle), ...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); }, }, }); return handle; }; const pick = (name) => dirHandle(name, { files: rolls[name].files, dirs: rolls[name].dirs }); // The roll in play, not the one this script was handed: a handle that comes // back out of the catalogue belongs to the folder being read, and the reading // a reload takes up is of that one. const root = () => pick(localStorage.getItem('check.roll') ?? folder); window.showDirectoryPicker = async () => root(); // What the disk holds, for the check to change behind the app's back: a frame // taken off the disk is a folder the walk meets one frame fewer in. window.__rolls = rolls; 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}`, folder: 'CheckRoll', small: SMALL, rolls: { // One frame at the top, one a folder down, one two folders down, one in a // second branch — and a folder holding no frame at all. CheckRoll: { files: [JPG], dirs: { 2026: { files: [RAW], dirs: { '04': { files: [JPG], dirs: {} } } }, 2025: { files: [JPG], dirs: {} }, Empty: { files: ['notes.txt'], dirs: {} }, }, }, // A roll with a folder that will not open: a permission taken back, a // volume pulled out — a folder the walk meets and cannot list. BrokenRoll: { files: [JPG], dirs: { Broken: { fail: true, files: [JPG], dirs: {} } }, }, // A roll long enough that its reading outlasts the wall's own work: sixty // frames read at a walking pace, so the wall has time to tile them while // the reading is still going. BigRoll: { files: LONG_NAMES, dirs: {}, }, // The same roll read for the first time, on a tab that goes away. CutRoll: { files: [JPG], dirs: { Broken: { fail: true, files: [JPG], dirs: {} } }, }, // A roll picked at a drive root, which Chromium hands over named `\` — the // one name a folder under it is spelled from and that is itself no folder // name at all. See the check at the foot of this file. '\\': { files: [JPG], dirs: { Roll: { files: [RAW], dirs: {} } }, }, }, } ); const page = await context.newPage(); const rowStates = () => page.$$eval('[data-key^="lib-node-"]', (els) => els.map((e) => `${e.dataset.key.replace('lib-node-', '')}:${(e.querySelector('.adm-album-count')?.textContent ?? '').trim()}`) ); const progress = () => page.$eval('[data-key="lib-progress"]', (e) => e.textContent ?? '').catch(() => ''); const counts = async () => { const out = {}; for (const row of await rowStates()) { const [key, n] = row.split(':'); out[key] = n; } return out; }; // Row order is the tree's path order, not the order of this file's expectations. const same = (a, b) => JSON.stringify(Object.entries(a).sort()) === JSON.stringify(Object.entries(b).sort()); // What the reading has stored, asked of the catalogue rather than of the strip: // the strip draws the open node's own frames, and three of these four are one // folder down. const stored = () => 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', 'readonly').objectStore('photos').count(); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); }) .catch(() => -1); // What the walk found, which is the whole of what the column is asked to say. const WANT = { CheckRoll: '4', 'CheckRoll/2025': '1', 'CheckRoll/2026': '2', 'CheckRoll/2026/04': '1', 'CheckRoll/Empty': '0', }; // --- a reading, watched while it runs --------------------------------------- await page.goto(`${base}/library`); // Held on the first frame it reads, so the walk is through its first pass — the // names and their numbers are the reading's, and the reading is not done. await page.evaluate(() => localStorage.setItem('check.hold', '1')); await page.click('[data-key="lib-add"]'); await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 30_000 }); // Every row of the pass is a row now, and a count beside each name is what the // walk met — the reading itself has read nothing. await page .waitForFunction(() => { const rows = [...document.querySelectorAll('[data-key^="lib-node-"]')]; return rows.length === 4 && /\d+\/\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''); }, null, { timeout: 60_000, polling: 50 }) .catch(() => {}); const midFloat = await counts(); const midLine = (await progress()).replace(/\s+/g, ' ').trim(); const midStored = await stored(); // The whole roll is one pass of four folders, so the numbers are the walk's the // moment the reading has listed the tree — before a single frame is read. step( 'the counts beside the folder names are up while the reading runs', same(midFloat, WANT), `${JSON.stringify(midFloat)} at "${midLine}"` ); step( 'and they are the walk’s own, not the catalogue’s: nothing has been read yet', midStored === 0, `${midStored} rows stored` ); step('and the reading is in flight while the column already says so', Boolean(await page.$('[data-key="lib-progress"]')), `"${midLine}"`); // --- the reading runs out ---------------------------------------------------- await page.evaluate(() => localStorage.removeItem('check.hold')); // The progress span is in the page whether or not a reading is, and its text is // empty until the first pass has said what the roll is: silence is the reading // being through. await page .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { timeout: 120_000, polling: 50, }) .catch(() => {}); const done = await counts(); step( 'a reading that ran out leaves the roll counted, empty folders and all', same(done, WANT), `${JSON.stringify(done)}, ${await stored()} rows stored` ); // --- the tab comes back ------------------------------------------------------ await page.reload(); await page.waitForSelector('[data-key="lib-node-CheckRoll"]', { timeout: 60_000 }); await page.waitForTimeout(1000); const back = await counts(); step( 'and the next visit draws the same column with nothing right-clicked', same(back, done), JSON.stringify(back) ); step( 'a folder the walk went into and met no frame in keeps its row', typeof back['CheckRoll/Empty'] === 'string', `Empty: ${back['CheckRoll/Empty']}` ); // Nothing is left to read, so this visit offers nothing and starts nothing: the // catalogue comes back off the disk and the screen waits to be asked. step( 'and a visit with nothing left to read reads nothing at all', !(await page.$('[data-key="lib-resume"]')) && (await progress()).trim() === '', `resume ${Boolean(await page.$('[data-key="lib-resume"]'))}, line "${(await progress()).trim()}"` ); // --- a reading cut off ------------------------------------------------------- // The catalogue is emptied first, so every frame is one this reading has to read // itself: a reading that does not go on by itself leaves the strip at nothing. await page.evaluate(async () => { sessionStorage.clear(); const db = await new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); await new Promise((resolve, reject) => { const tx = db.transaction('photos', 'readwrite'); tx.objectStore('photos').clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); localStorage.setItem('check.hold', '1'); }); const counting = () => page .waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, { timeout: 60_000, polling: 50, }) .then(() => progress()) .then((line) => line.replace(/\s+/g, ' ').trim()) .catch(() => ''); // A reading of the whole roll, held on the first frame it reads: what a tab that // is closed mid-reading leaves behind is exactly this. await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 }); await page.click('[data-key="lib-rescan-CheckRoll"]'); const cut = await counting(); // The walk writes its own position out at most once a second, and the reload is // what has to be cut off mid-reading rather than before it started. await page.waitForTimeout(2000); // Nothing here asks for another reading, and neither does the visit that comes // back: the roll the reload cut off is put on offer beside the progress line, and // the press of that offer is the only thing that starts the second reading. The // first frame is still held, so a visit that went on by itself would hang on it // rather than pass. await page.reload(); await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 }); const offered = (await progress()).trim(); step( 'a reading a reload cut off is offered by the next visit, not run by it', /\d+\/\d+/.test(cut) && offered === '', `"${cut}" then idle "${offered}"` ); await page.click('[data-key="lib-resume"]'); const cutAgain = await counting(); step('and the offer is that reading taken up where it stopped', /\d+\/\d+/.test(cutAgain), `"${cutAgain}"`); await page.evaluate(() => localStorage.removeItem('check.hold')); // The progress span is in the page whether or not a reading is, and its text is // empty until the first pass has said what the roll is: silence is the reading // being through. await page .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { timeout: 120_000, polling: 50, }) .catch(() => {}); const resumed = await stored(); const lists = await page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}')); step( 'and the visit after it finishes the roll on its own, without listing a folder twice', resumed === 4 && lists.CheckRoll === 1 && lists['2026'] === 1 && lists.Empty === 1 && lists['2025'] === 1, `${resumed} rows stored from ${JSON.stringify(lists)}` ); const keys = (store) => page .evaluate( (store) => new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); req.onsuccess = () => { const db = req.result; const get = db.transaction(store, 'readonly').objectStore(store).getAllKeys(); get.onsuccess = () => resolve(get.result); get.onerror = () => reject(get.error); }; req.onerror = () => reject(req.error); }), store ) .catch(() => []); const quiet = () => page .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { timeout: 120_000, polling: 50, }) .catch(() => {}); // A reading, watched out: the line is up once it has told the column what the // roll is, and silence is the reading being through. Waiting for silence alone // would read the line before the reading has written it. const readOut = async () => { await page .waitForFunction(() => /\d+\s*\/\s*\d+/.test(document.querySelector('[data-key="lib-progress"]')?.textContent ?? ''), null, { timeout: 20_000, polling: 50, }) .catch(() => {}); await quiet(); }; // A row for a frame the disk has not got, with the tile and the marks that go // with it — the state a catalogue is left in when a frame is taken off the disk. const seed = (ids) => page.evaluate( (ids) => new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); req.onsuccess = () => { const db = req.result; const tx = db.transaction(['photos', 'thumbs', 'edits'], 'readwrite'); for (const id of ids) { const parts = id.split('/'); tx.objectStore('photos').put({ id, folder: parts[0], dir: parts.slice(1, -1).join('/'), name: parts[parts.length - 1], handle: { kind: 'file', name: parts[parts.length - 1], __fake: true }, taken: 1, size: 1, mtime: 1, addedAt: 1, }); tx.objectStore('thumbs').put({ id, thumb: new Blob(['x']) }); tx.objectStore('edits').put({ photoId: id, recipe: {}, updatedAt: 1 }); } tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }; req.onerror = () => reject(req.error); }), ids ); // --- the catalogue brought to the disk -------------------------------------- // The reader's report: a folder's frames and the number kept for it disagree, and // a rescan of the parent does not put them right. Both halves are here — a frame // taken off the disk while its row stayed in the catalogue, and a row for a frame // that was never on the disk at all — and one rescan of the roll has to settle // both, the numbers under the subfolders included. await seed(['CheckRoll/2025/Gone.JPG', 'BrokenRoll/Broken/x.JPG']); await page.evaluate(() => { window.__rolls.CheckRoll.dirs['2025'].files.length = 0; }); step( 'the catalogue is holding two rows the disk has nothing for', (await keys('photos')).includes('CheckRoll/2025/Gone.JPG') && (await keys('photos')).includes('CheckRoll/2025/P1010256.JPG'), JSON.stringify(await keys('photos')) ); await page.click('[data-key="lib-node-CheckRoll"]', { button: 'right' }); await page.waitForSelector('[data-key="lib-rescan-CheckRoll"]', { timeout: 10_000 }); await page.click('[data-key="lib-rescan-CheckRoll"]'); await readOut(); const repaired = await counts(); const rows = (await keys('photos')).filter((k) => k.startsWith('CheckRoll/')); step( 'a rescan of the parent brings every number under it to the disk', same(repaired, { ...WANT, CheckRoll: '3', 'CheckRoll/2025': '0', }), JSON.stringify(repaired) ); step( 'and the frames the reading did not meet are gone from the catalogue', rows.length === 3 && !rows.includes('CheckRoll/2025/P1010256.JPG') && !rows.includes('CheckRoll/2025/Gone.JPG'), JSON.stringify(rows) ); step( 'with their tiles and their marks', !(await keys('thumbs')).includes('CheckRoll/2025/Gone.JPG') && !(await keys('edits')).includes('CheckRoll/2025/Gone.JPG'), JSON.stringify(await keys('edits')) ); // --- a folder the walk could not open --------------------------------------- // The other side of the same rule: the walk met nothing under `Broken`, and a // reading that read silence for absence would empty a folder it never listed. // The number beside its name is the catalogue's, which is the last reading that // could open it: a reading that could not open a folder writes no nought there — // a nought takes the place of the frames the catalogue holds and reads as a // folder gone empty until the reader rescans the roll. await page.evaluate(() => localStorage.setItem('check.roll', 'BrokenRoll')); await page.click('[data-key="lib-add"]'); await page.waitForSelector('[data-key="lib-node-BrokenRoll"]', { timeout: 60_000 }); await readOut(); const broken = await counts(); step('a folder the walk could not open keeps its row', broken['BrokenRoll/Broken'] === '1', JSON.stringify(broken)); step( 'and nothing under it is dropped: silence is not absence', (await keys('photos')).includes('BrokenRoll/Broken/x.JPG'), JSON.stringify(await keys('photos')) ); step( 'while the frame the walk did meet is stored', (await keys('photos')).includes('BrokenRoll/P1010256.JPG'), JSON.stringify(await keys('photos')) ); // --- a folder the reading could not open, and a tab that goes away ----------- // Why the number beside a folder stays at nothing through update after update, // which is the state the reader photographed. A roll is read for the first time // and the tab goes while the reading is in it: the position it leaves behind must // not say it walked a folder it could not open, because an update takes that // position up — a folder filed as read is a folder no update opens again, and its // number stays at nothing however many times the reader asks. The folder opens on // the visit that takes the reading up, and the reading is there to open it. const opens = () => page.evaluate(() => JSON.parse(sessionStorage.getItem('check.lists') ?? '{}')); const opened = (m, name) => m[name] ?? 0; await page.evaluate(async () => { sessionStorage.removeItem('check.lists'); localStorage.setItem('check.roll', 'CutRoll'); localStorage.setItem('check.hold', '1'); // Nothing in hand: the frame is one this reading has to read itself, which is // what the hold is holding — and a reading held on the frame it is reading is // the reading a closed tab leaves behind. const db = await new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); await new Promise((resolve, reject) => { const tx = db.transaction('photos', 'readwrite'); tx.objectStore('photos').clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }); await page.click('[data-key="lib-add"]'); await page.waitForSelector('[data-key="lib-node-CutRoll"]', { timeout: 60_000 }); await counting(); await new Promise((r) => setTimeout(r, 1500)); const askedBroken = opened(await opens(), 'Broken'); const held = await page.evaluate(async () => { const root = await navigator.storage.getDirectory(); try { const dir = await root.getDirectoryHandle('walk', { create: false }); const fh = await dir.getFileHandle(`${encodeURIComponent('CutRoll')}.json`, { create: false }); return JSON.parse(await (await fh.getFile()).text()); } catch { return null; } }); step( 'the position a closed tab leaves files no folder it could not open as walked', !!held && !held.walked.some((r) => r.startsWith('Broken')) && held.pending.includes('Broken'), JSON.stringify(held && { walked: held.walked, pending: held.pending }) ); // The tab goes, and the roll it cut off is offered on the visit that comes back: // the offer is pressed, and the reading it takes up is the reading that opens the // folder the last one could not. await page.evaluate(() => localStorage.setItem('check.broken', 'open')); await page.reload(); await page.waitForSelector('[data-key="lib-resume"]', { timeout: 60_000 }); await page.click('[data-key="lib-resume"]'); const again = await counting(); await page.evaluate(() => localStorage.removeItem('check.hold')); await readOut(); const restarted = await counts(); step( 'and the visit that takes the reading up is the visit that opens it', opened(await opens(), 'Broken') > askedBroken, `Broken asked ${askedBroken} times, then ${opened(await opens(), 'Broken')}` ); step( 'so the folder is counted on the disk, not left at nothing', restarted['CutRoll'] === '2' && restarted['CutRoll/Broken'] === '1', JSON.stringify(restarted) ); // --- a roll picked at a drive root ------------------------------------------ // The reader picks a whole drive and every number beside a folder name stands at // nought. The picker hands that folder over named `\`, and a row's number is // looked up by the path that name spells: swapped for a bare backslash the roll // is `/` and its folders `//Roll`, while the walk files its frames — and writes // its counts — under the empty root a `normPath` fold leaves behind. Two // spellings of one folder, and a column of noughts over a roll full of frames. await page.evaluate(async () => { sessionStorage.removeItem('check.lists'); localStorage.removeItem('check.hold'); localStorage.setItem('check.roll', '\\'); const db = await new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); await new Promise((resolve, reject) => { const tx = db.transaction('photos', 'readwrite'); tx.objectStore('photos').clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }); await page.click('[data-key="lib-add"]'); await page.waitForSelector('[data-key="lib-node-/Roll"]', { timeout: 60_000 }).catch(() => {}); // The reading runs out on its own, and silence is the reading being through. await page .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { timeout: 120_000, polling: 50, }) .catch(() => {}); const drive = await counts(); step( 'a roll picked at a drive root counts its frames beside its own name', drive[''] === '2' && drive['/Roll'] === '1', JSON.stringify(drive) ); // --- the shelf, out of a reading --------------------------------------------- // The reading hands the screen the frames it found a batch at a time, and a batch // used to be handed on only when it was full: the reader's folder of 26 RAWs is // under one batch, so the screen stood empty for the whole of the reading and then // filled at once, and the tiles had nothing to be drawn from until the reading was // over. Frames go down on the clock as well now, so the count climbs and the first // tiles appear while the roll is still being read. Sixty frames read at a walking // pace is a long roll read the way the reader read theirs. await page.evaluate(async () => { sessionStorage.removeItem('check.lists'); localStorage.removeItem('check.hold'); localStorage.setItem('check.slow', '900'); localStorage.setItem('check.roll', 'BigRoll'); const db = await new Promise((resolve, reject) => { const req = indexedDB.open('recipescam-library'); req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }); await new Promise((resolve, reject) => { const tx = db.transaction(['photos', 'thumbs'], 'readwrite'); tx.objectStore('photos').clear(); tx.objectStore('thumbs').clear(); tx.oncomplete = () => resolve(); tx.onerror = () => reject(tx.error); }); }); await page.click('[data-key="lib-add"]'); await page.waitForSelector('[data-key="lib-node-BigRoll"]', { timeout: 60_000 }); await page .waitForFunction(() => (document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim().length > 0, null, { timeout: 60_000, polling: 25, }) .catch(() => {}); // The reading is in flight and the frames are being read: what is asked of it here // is whether the screen is told of them as they land. A roll under one `BATCH` // reached the screen in a single lump at the end of the reading, however long the // reading was — the reader's folder of 26 RAWs was one batch, and so one empty // screen for the whole of it. Every count between none and all is a reading that // said something on the way past. const landed = []; for (let i = 0; i < 120; i++) { const reading = await page .$eval('[data-key="lib-progress"]', (e) => (e.textContent ?? '').trim()) .catch(() => ''); const listed = Number( (await page.$eval('[data-key="lib-listed"]', (e) => e.textContent ?? '').catch(() => '0')).replace(/\D+/g, '') ); if (reading && listed > 0) landed.push(listed); if (!reading) break; await new Promise((r) => setTimeout(r, 150)); } const seen = [...new Set(landed)]; step( 'the frames the reading lands are on screen as it lands them', seen.length >= 2, `${seen.length} counts while the reading ran: ${seen.join(', ') || 'none'}` ); await page .waitForFunction(() => !(document.querySelector('[data-key="lib-progress"]')?.textContent ?? '').trim(), null, { timeout: 120_000, polling: 25, }) .catch(() => {}); // The frames the wall draws are read one behind another and the tiles land in // starts and stops, so the count is taken once it has stood still for a while. let atEnd = 0; for (let i = 0, same = 0; i < 160 && same < 10; i++) { await new Promise((r) => setTimeout(r, 250)); const n = (await keys('thumbs')).length; same = n === atEnd ? same + 1 : 0; atEnd = n; } step('the wall tiles the frames a reading brought it', atEnd >= 10, `${atEnd} tiles of ${LONG} drawn`); await page.evaluate(() => localStorage.removeItem('check.slow')); await browser.close(); app.kill('SIGTERM'); console.log(failures ? `\n${failures} failed` : '\nall checks passed'); process.exit(failures ? 1 : 0);