fix(library): land a reading's rows as they are read, and keep the copy the reader reads offline
- scan: rows go down on a clock as well as on a full batch. The reader's folder of 26 RAWs is under one batch of 50, so nothing of it reached the screen until the reading was over — a wall that stood empty for the whole scan and then filled in one go. Frames now reach the screen every 400ms while the roll is still being read, so the count climbs and the first tiles are drawn as the frames land. - tiles: a HEIC or a RAW has its tile written the first time it is drawn, so every later visit reads the tile back instead of the file. - resume: a reading a reload cut off is offered by the next visit with a chip instead of being run behind the reader's back. - offline listing: the desk's listing is kept whole in IndexedDB under `recipescam-offline`/`reads`, past the 200 000 characters localStorage held, so an offline visit opens on the listing it kept instead of on nothing. - offline writes: a vote or a view cast with no server is held and goes out when one answers again, with a bar that says how many are waiting. - sw: the shell precaches its own bundle, stylesheet and icon, read out of the document — a worker that precached only the document answered an offline launch with a page whose JavaScript the machine did not have. - checks in docker/frontend/scripts: tree-scan-check (counts while reading, resume after a reload, tiles of a long roll), offline-read-check, offline-queue-check.
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
// The offline answer to a big read, checked in a browser: the strip's listing is
|
||||
// a few hundred kilobytes and used to have no offline answer at all, because the
|
||||
// last one was kept in localStorage under a cap of two hundred thousand
|
||||
// characters. It is kept whole in IndexedDB now, and an offline visit opens on it.
|
||||
//
|
||||
// The API is stubbed on the page's own origin the way the container's nginx
|
||||
// serves it, and "no server" is a route that fails the request — which is exactly
|
||||
// what a dead server looks like from `fetch`.
|
||||
//
|
||||
// npm run build && node scripts/offline-read-check.mjs
|
||||
// APP_PORT=4886 PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> \
|
||||
// node scripts/offline-read-check.mjs
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const FRONTEND = fileURLToPath(new URL('..', import.meta.url));
|
||||
const APP_PORT = Number(process.env.APP_PORT ?? 4187);
|
||||
// The cap the read used to be kept under, and the store it is kept in now.
|
||||
const OLD_CAP = 200_000;
|
||||
const DB = 'recipescam-offline';
|
||||
const READS = 'reads';
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// The listing as a studio with a real catalogue has it: enough frames that the
|
||||
// JSON is past the old cap on its own, each carrying the look it was saved with —
|
||||
// a curve is the bulk of one, and a hundred points of it is a small one.
|
||||
const PHOTOS = Array.from({ length: 60 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
slots: ['strip'],
|
||||
tag: `#P${i + 1}`,
|
||||
title: `Frame ${i + 1}`,
|
||||
meta: `ISO 400 · 35mm`,
|
||||
consent: true,
|
||||
hasPreset: false,
|
||||
recipe: { name: 'PROVIPES', curve: Array.from({ length: 512 }, (_, n) => n / 512) },
|
||||
}));
|
||||
const LISTING = JSON.stringify({ photos: PHOTOS });
|
||||
|
||||
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}/`).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();
|
||||
|
||||
let gone = false; // whether the page's server is there
|
||||
|
||||
await context.route('**/api/**', (route) => {
|
||||
if (gone) return route.abort('failed');
|
||||
const req = route.request();
|
||||
const url = req.url();
|
||||
const json = (body) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
|
||||
if (url.includes('/auth/me')) return json({ user: null });
|
||||
if (url.includes('/photos')) return route.fulfill({ status: 200, contentType: 'application/json', body: LISTING });
|
||||
if (url.includes('/ratings')) return json({ ratings: {} });
|
||||
if (url.includes('/highlights')) return json({ highlights: { day: [], week: [], ever: [] } });
|
||||
if (url.includes('/recipes')) return json({ recipes: [] });
|
||||
return json({});
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
const noise = [];
|
||||
page.on('console', (m) => {
|
||||
// The outage is the check's own doing, and a request that fails is a request
|
||||
// the browser logs. Everything else is a real complaint.
|
||||
if (m.type() === 'error' && !m.text().includes('net::ERR_FAILED')) noise.push(m.text());
|
||||
});
|
||||
const frames = () => page.$$eval('.lp-frame', (els) => els.length);
|
||||
const readStale = (key) =>
|
||||
page
|
||||
.evaluate(
|
||||
([db, store, key]) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(db, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(store)) req.result.createObjectStore(store);
|
||||
};
|
||||
req.onsuccess = () => {
|
||||
const d = req.result;
|
||||
if (!d.objectStoreNames.contains(store)) return resolve(null);
|
||||
const get = d.transaction(store, 'readonly').objectStore(store).get(key);
|
||||
get.onsuccess = () => resolve(get.result ?? null);
|
||||
get.onerror = () => reject(get.error);
|
||||
};
|
||||
req.onerror = () => reject(req.error);
|
||||
}),
|
||||
[DB, READS, key]
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
step('the listing this check serves is past the cap the old code kept', LISTING.length > OLD_CAP, `${LISTING.length} chars`);
|
||||
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.lp-frame', { timeout: 60_000 });
|
||||
await page.waitForFunction(() => document.querySelectorAll('.lp-frame').length >= 60, null, { timeout: 30_000 }).catch(() => {});
|
||||
const online = await frames();
|
||||
|
||||
const stored = await readStale('/photos');
|
||||
step(
|
||||
'a listing past the old cap is kept for offline',
|
||||
typeof stored === 'string' && stored.length > OLD_CAP,
|
||||
typeof stored === 'string' ? `${stored.length} chars in the store` : 'no record'
|
||||
);
|
||||
|
||||
const leftBehind = await page.evaluate(() => Object.keys(localStorage).filter((k) => k.startsWith('rc.stale.')).length);
|
||||
step('and nothing is left in localStorage under the old key', leftBehind === 0, `${leftBehind} keys`);
|
||||
|
||||
const ratings = await readStale('/ratings');
|
||||
step('a small read is kept the same way', typeof ratings === 'string', ratings === null ? 'no record' : 'kept');
|
||||
|
||||
// The reload with no server: the listing is the last one it was shown, so the
|
||||
// reel is the same reel rather than an empty band.
|
||||
gone = true;
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector('.lp-frame', { timeout: 30_000 }).catch(() => {});
|
||||
const offline = await frames();
|
||||
step(
|
||||
'and the offline visit opens on the listing it kept',
|
||||
online >= 60 && offline === online,
|
||||
`${online} frames online, ${offline} offline`
|
||||
);
|
||||
step('nothing thrown along the way', noise.length === 0, noise[0] ?? '');
|
||||
|
||||
await browser.close();
|
||||
app.kill('SIGTERM');
|
||||
console.log(failures === 0 ? 'all pass' : `${failures} failed`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user