// 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= \ // 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);