// Install support is not visible in the source. It is the manifest the browser // reads, the worker it registers, and what that worker still holds once the network // is gone — so unlike the other checks here, which read a module and assert on it, // this one has to drive a real browser at a real build. Four assertions, one line // each, against a preview server: // // npx vite build && npx vite preview --port 4183 & // node scripts/pwa-check.mjs // // The browser is Playwright's own, installed by whoever runs this — there is no // test runner in this repo, and software GL is what the studio needs to paint at // all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser. const BASE = process.argv[2] ?? 'http://localhost:4183'; const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); if (!playwright) { console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); process.exit(0); } const { chromium } = playwright; const browser = await chromium.launch({ // Playwright's own Chromium when it has one; CHROME points at any other. executablePath: process.env.CHROME || undefined, args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], }); const context = await browser.newContext(); // Every assertion prints, rather than throwing on the first one: which of the four // works and which does not is the whole answer. let failed = 0; const check = (ok, label) => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`); if (!ok) failed++; }; const page = await context.newPage(); await page.goto(`${BASE}/app`); // (a) The manifest, as the browser fetches it before it offers anything. const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json()); check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`); // (b) The worker registration main.tsx makes on load. `ready` only ever resolves // with a registration that has an `active` worker; that worker's own state string // is 'activated' (the ServiceWorkerState enum has no 'active' in it). const registration = await page.evaluate(async () => { const reg = await navigator.serviceWorker.ready; return { scope: reg.scope, state: reg.active?.state ?? null }; }); check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`); // (c) What it put away. Read after a reload, because the first load is the one that // installs it and the reload is the first one it is in control of. await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined); await page.reload(); const cached = await page.evaluate(async () => { const out = {}; for (const name of await caches.keys()) { out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname); } return out; }); const names = Object.keys(cached); const urls = Object.values(cached).flat().sort(); check( urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)), `worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`, ); // (d) The point of all of it: the network gone, the shell still paints. await context.setOffline(true); let brand = 'nothing'; try { await page.reload(); brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null); } catch (err) { brand = `reload failed: ${err.message.split('\n')[0]}`; } check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`); await browser.close(); if (failed) { console.error(`pwa-check: ${failed} of 4 failed`); process.exit(1); } console.log('pwa-check ok');