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