117 lines
5.0 KiB
JavaScript
117 lines
5.0 KiB
JavaScript
// 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. Five 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=<path to its index.mjs>');
|
|
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 five
|
|
// 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}`);
|
|
|
|
// (e) The offer is asked once. The browser's sheet is stubbed, because a check cannot
|
|
// click the real one: waved away it is a refusal, and a refusal is stored, so the next
|
|
// visit renders no dialog at all. The stub goes in an init script so it is there on
|
|
// both loads.
|
|
await context.setOffline(false);
|
|
await page.addInitScript(() => {
|
|
window.__offerInstall = () => {
|
|
const event = new Event('beforeinstallprompt');
|
|
event.prompt = () => Promise.resolve();
|
|
event.userChoice = Promise.resolve({ outcome: 'dismissed' });
|
|
window.dispatchEvent(event);
|
|
window.dispatchEvent(new Event('rc:install-offer'));
|
|
};
|
|
});
|
|
const offer = async () => {
|
|
await page.evaluate(() => window.__offerInstall());
|
|
await page.waitForTimeout(150);
|
|
return page.locator('[data-key="install-yes"]').count();
|
|
};
|
|
await page.reload();
|
|
const first = await offer();
|
|
await page.locator('[data-key="install-yes"]').click();
|
|
const stored = await page.evaluate(() => localStorage.getItem('rc.install.dismissed.v1'));
|
|
await page.reload();
|
|
const again = await offer();
|
|
check(
|
|
first === 1 && stored === '1' && again === 0,
|
|
`install dialog shown ${first} time(s), refused (localStorage '${stored}'), shown again after reload: ${again}`,
|
|
);
|
|
|
|
await browser.close();
|
|
if (failed) {
|
|
console.error(`pwa-check: ${failed} of 5 failed`);
|
|
process.exit(1);
|
|
}
|
|
console.log('pwa-check ok');
|