1cb2618fd7
Two holes left by the last change. A frame is opened with `window.location.href` rather than a link, so the studio was still handed a fresh page and the scan with it — every route out of the catalogue now goes through `go()`, which pushes the address instead of reloading while a scan is in flight. And a row counted what was filed away rather than what had been read, so it sat at zero for the whole of a first scan: the catalogue's frames only reach IndexedDB in one batch at the end. `ScanProgress.counts` carries the frames the scan has reached, per row, off the scan's own bookkeeping, and the column reads it while the scan runs — the row under the reader's eye moves as the roll is read, and the toolbar's line stays the whole picture. A re-read counts from the start, which is what it is doing. The regression check grows the two: a frame opened mid-scan from the catalogue (of a roll it already holds) has to stay in the page, and the row has to count.
287 lines
13 KiB
JavaScript
287 lines
13 KiB
JavaScript
// 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);
|