426488a788
The reading of a folder belongs to the tab, not to the catalogue screen: it was
made to survive the hand-over to the studio in 753eea0, and it does. What was
missing was any sign of it up there — a visitor who hands a half-read roll to the
studio saw a page that said nothing about the frames still landing, and had no
way to tell a reading in flight from one that had quietly died. The one thing
that did say so, the toolbar line, lived on the screen they had just left.
The header now carries it, immediately left of the way back into the catalogue,
where the two belong together: the ring the catalogue already uses for a roll in
hand, and the count the toolbar states, "4/12", whose title is the same
"Scanning 4/12 — 0 new…" line in the visitor's language. It reads the session
through scanSession() and watches it with watchScan() exactly as the catalogue
does, so a pass that moves the progress moves the header too — the session object
outlives them both, only its progress is replaced each pass. No new state is
introduced, no new copy: the markup borrows .lib-spin and the lib.scanning key
the catalogue already had.
Verified: tsc --noEmit and vite build clean; scripts/scan-nav-check.mjs grew one
step at the first hand-over, "the studio header shows the reading the tab is
doing", which waits for [data-key="studio-scan-count"] mid-scan and matches it
against \d+/\d+ — it passes with 4/12, the same reading the toolbar shows at that
moment; the other 18 steps of that check, the 52 steps of library-check.mjs and
roll-walk-check.mjs all still pass.
ponytail: the header states progress, it does not offer to stop the scan; the
catalogue's own STOP stays the one place that ends a reading. Add a control here
when someone asks to stop a roll from the studio.
Co-authored-by: PenguinHarness <noreply@penguin.local>
294 lines
13 KiB
JavaScript
294 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());
|
|
// The reading belongs to the tab, so the header of the screen that did not start
|
|
// it says it is still going, beside the way back into the catalogue.
|
|
const badge = await page
|
|
.waitForSelector('[data-key="studio-scan-count"]', { timeout: 15_000 })
|
|
.then((el) => el.textContent())
|
|
.catch(() => null);
|
|
step('the studio header shows the reading the tab is doing', /^\d+\/\d+$/.test(badge ?? ''), String(badge));
|
|
|
|
// 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);
|