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,152 @@
|
||||
// The offline queue, checked in a browser: a write made with no server is held
|
||||
// rather than lost, a second write to the same route replaces the first, the
|
||||
// reload keeps it, and a server answering sends it on its own.
|
||||
//
|
||||
// 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-queue-check.mjs
|
||||
// APP_PORT=4885 PLAYWRIGHT_CORE=<path to playwright-core/index.mjs> \
|
||||
// node scripts/offline-queue-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 ?? 4186);
|
||||
// The key pwa/offline.ts holds the queue under.
|
||||
const KEY = 'rc.pending';
|
||||
|
||||
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 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
|
||||
const sent = []; // every write the stub let through
|
||||
const PHOTO = {
|
||||
id: 1,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
slots: ['strip'],
|
||||
tag: '#CHECK',
|
||||
title: 'Check frame',
|
||||
meta: '#1',
|
||||
consent: true,
|
||||
};
|
||||
|
||||
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 (req.method() !== 'GET') {
|
||||
sent.push({ method: req.method(), path: new URL(url).pathname, body: req.postData() });
|
||||
return json({ key: 'photo:1', rating: { avg: 5, n: 1, mine: 5 } });
|
||||
}
|
||||
if (url.includes('/auth/me')) return json({ user: { id: 1, email: 'check@example.com' } });
|
||||
if (url.includes('/photos')) return json({ photos: [PHOTO] });
|
||||
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({});
|
||||
});
|
||||
|
||||
// The bar's own words are asserted, so the page starts in Vietnamese.
|
||||
await context.addInitScript(() => localStorage.setItem('rc.lang', 'vi'));
|
||||
|
||||
const page = await context.newPage();
|
||||
const star = (n) => `[data-key="lp-star-photo:1-${n}"]`;
|
||||
const bar = '[data-key="offline-bar"]';
|
||||
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 queued = () =>
|
||||
page.evaluate((k) => JSON.parse(localStorage.getItem(k) ?? '[]'), KEY);
|
||||
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector(star(5), { timeout: 60_000 });
|
||||
|
||||
// 1 — a write with no server is held, not lost.
|
||||
gone = true;
|
||||
await page.$eval(star(5), (el) => el.click());
|
||||
await page
|
||||
.waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 1, KEY, { timeout: 15_000 })
|
||||
.catch(() => {});
|
||||
let held = await queued();
|
||||
step(
|
||||
'a vote cast with no server is held',
|
||||
held.length === 1 && held[0].method === 'POST' && held[0].path === '/ratings',
|
||||
JSON.stringify(held)
|
||||
);
|
||||
const notice = await page.textContent(bar).catch(() => null);
|
||||
step('and the bar says how many are waiting', notice !== null && notice.includes('Chưa gửi được: 1'), notice ?? 'no bar');
|
||||
|
||||
// 2 — the same vote again replaces the held one instead of stacking.
|
||||
await page.$eval(star(3), (el) => el.click());
|
||||
await page.waitForTimeout(500);
|
||||
held = await queued();
|
||||
step(
|
||||
'a second vote on the same frame is one write, the later one',
|
||||
held.length === 1 && String(held[0].body).includes('"stars":3'),
|
||||
JSON.stringify(held)
|
||||
);
|
||||
|
||||
// 3 — the reload keeps it.
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForSelector(star(5), { timeout: 60_000 });
|
||||
held = await queued();
|
||||
step('and it is still held across a reload with no server', held.length === 1, JSON.stringify(held));
|
||||
|
||||
// 4 — a server answering sends it, with no one asking.
|
||||
gone = false;
|
||||
await page.goto(base, { waitUntil: 'domcontentloaded' });
|
||||
await page
|
||||
.waitForFunction((k) => JSON.parse(localStorage.getItem(k) ?? '[]').length === 0, KEY, { timeout: 30_000 })
|
||||
.catch(() => {});
|
||||
held = await queued();
|
||||
step(
|
||||
'the held write goes out when a server answers',
|
||||
held.length === 0 &&
|
||||
sent.some((s) => s.method === 'POST' && s.path === '/api/ratings' && s.body === '{"key":"photo:1","stars":3}'),
|
||||
JSON.stringify(sent)
|
||||
);
|
||||
step('and the bar goes with it', (await page.$(bar)) === null);
|
||||
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