79b0d0db86
The catalogue hands the visitor to the studio with a plain `<a href>`, and the app has no router: following it threw the page away, so a scan that was halfway through a roll died with it. A scan belongs to the tab, not to the screen that started it. The scan now lives outside the component (`scanSession`/`startScan`/`stopScan`, watched by whoever is up), so the catalogue can unmount and come back to a reading that never stopped; a screen that returns joins it and sees the toolbar line, the stop button and the rows filling in. The internal links are taken over for a history push *only while a scan is in flight* — everywhere else the browser navigates exactly as before, so the landing-to-studio flow is untouched. `scripts/scan-nav-check.mjs` is the regression: a roll read one frame at a time off a slow server, handed to the studio mid-scan, back to the catalogue, and the whole roll read to the end with the studio up, counting documents along the way.
237 lines
9.4 KiB
JavaScript
237 lines
9.4 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: 1 means the click stayed in
|
|
// the page, 2 means the page was thrown away and reloaded.
|
|
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);
|
|
};
|
|
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) => {
|
|
const handle = { kind: 'directory', name: label };
|
|
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);
|
|
|
|
// 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 docs()) === 1, `${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 docs()) === 1, `${await docs()} document load(s)`);
|
|
const progress = await progressText(15_000);
|
|
step('the catalogue comes back showing the scan it missed', !!progress, String(progress));
|
|
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 docs()) === 1, `${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));
|
|
|
|
await browser.close();
|
|
app.kill('SIGTERM');
|
|
sampleServer.close();
|
|
console.log(failures ? `\n${failures} step(s) failed` : '\nall steps passed');
|
|
process.exit(failures ? 1 : 0);
|