Gate the installability probe on every condition LAUNCH.md claims (Maestro 2c8f8887, F1-F3)
CI / ci (push) Successful in 10m9s
CI / ci (pull_request) Successful in 10m12s

F1: the probe now gates each page on manifest HTTP 200, name, display,
start_url, 192x192 and 512x512 icons, a maskable icon, and a service
worker that is ready, registered and activated. It prints a PASS/FAIL
line per condition and exits 1 if any condition fails on any page.
LAUNCH.md lists exactly these gates; the unchecked claim that the worker
controls the page is removed.

F2: navigator.serviceWorker.ready is raced against a 10 second timeout;
expiry fails the "service worker ready" condition.

F3: the browser context uses Playwright's Pixel 5 mobile emulation
(viewport, touch, isMobile, user agent), and LAUNCH.md says so.

Re-run against production mealquest:743a155 on 2026-10-11: every
condition PASS on all four pages, exit 0.

Maestro task: 2c8f8887-85e5-4734-b90f-9678f17d3778

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 20:28:03 -04:00
co-authored by Claude Opus 5.5
parent b19dad7b40
commit cd4bc8d9bb
2 changed files with 49 additions and 26 deletions
+11 -10
View File
@@ -36,18 +36,19 @@ Performance is recorded for reference but is not gated.
Lighthouse 10 and later removed the PWA category, so installability was checked Lighthouse 10 and later removed the PWA category, so installability was checked
directly with a Playwright probe: directly with a Playwright probe:
[installability-check.mjs](lighthouse/2026-10-11/installability-check.mjs). [installability-check.mjs](lighthouse/2026-10-11/installability-check.mjs).
It loads each of the four pages above in a signed-in mobile Chrome session It loads each of the four pages above in a signed-in Chrome session with
(the cookie comes from the `CK` environment variable, never the file) and Playwright's Pixel 5 mobile emulation (viewport, touch, `isMobile` and mobile
checks: user agent; the cookie comes from the `CK` environment variable, never the
file) and gates every page on all of:
- **Manifest** — the linked manifest is fetched (HTTP 200) and has - **Manifest** — the linked manifest is fetched with HTTP 200 and has
`name` "MealQuest", `display` "standalone", a `start_url`, and icons at `name` "MealQuest", `display` "standalone", a `start_url`, and icons at
192 px, 512 px and a `maskable` purpose. 192x192, 512x512 and one with `maskable` purpose.
- **Service worker** — registered and activated, and controlling the page on - **Service worker** — `navigator.serviceWorker.ready` resolves within 10
the single-page-app routes. seconds (expiry is a failure), and a registration exists whose active worker
is `activated`.
The script's pass condition gates the manifest fields and icons and the It prints a PASS or FAIL line per condition per page and exits 1 if any
service-worker registration; the manifest HTTP status, the worker's activation condition fails on any page.
state and whether it controls the page are printed per page for review.
**Result: PASS on all four pages.** **Result: PASS on all four pages.**
@@ -1,31 +1,53 @@
import { chromium } from 'playwright'; import { chromium, devices } from 'playwright';
const ck = process.env.CK; const ck = process.env.CK;
if (!ck) { console.error('CK (session cookie) is not set'); process.exit(2); }
const origin = 'https://mealquest.cubecraftlabs.com';
const pages = [['home','/'],['plan','/plan'],['discover','/discover'],['dinner','/dinner/01M4PKT9QYA6C4JCF67XE7RA6R']]; const pages = [['home','/'],['plan','/plan'],['discover','/discover'],['dinner','/dinner/01M4PKT9QYA6C4JCF67XE7RA6R']];
const SW_READY_TIMEOUT_MS = 10000;
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] }); const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox'] });
const ctx = await browser.newContext({ userAgent: 'Mozilla/5.0 (Linux; Android 11) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Mobile Safari/537.36' }); // Mobile emulation: Pixel 5 viewport, device scale, touch, isMobile and user agent.
await ctx.addCookies([{ name: '__Host-mealquest_session', value: ck, url: 'https://mealquest.cubecraftlabs.com/' }]); const ctx = await browser.newContext({ ...devices['Pixel 5'] });
await ctx.addCookies([{ name: '__Host-mealquest_session', value: ck, url: origin + '/' }]);
let fail = 0; let fail = 0;
for (const [name, path] of pages) { for (const [name, path] of pages) {
const page = await ctx.newPage(); const page = await ctx.newPage();
page.on('console', m => { if (/sw|service|error/i.test(m.text())) console.log(' [console]', m.text().slice(0,120)); }); page.on('console', m => { if (/sw|service|error/i.test(m.text())) console.log(' [console]', m.text().slice(0,120)); });
page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0,120))); page.on('pageerror', e => console.log(' [pageerror]', String(e).slice(0,120)));
await page.goto('https://mealquest.cubecraftlabs.com' + path, { waitUntil: 'networkidle', timeout: 30000 }); await page.goto(origin + path, { waitUntil: 'networkidle', timeout: 30000 });
const href = await page.evaluate(() => document.querySelector('link[rel="manifest"]')?.href || null); const href = await page.evaluate(() => document.querySelector('link[rel="manifest"]')?.href || null);
const sw = await page.evaluate(async () => { const sw = await page.evaluate(async (ms) => {
try { await navigator.serviceWorker.ready; } catch (e) { return { err: String(e) }; } const ready = await Promise.race([
navigator.serviceWorker.ready.then(() => 'ready', e => 'error: ' + String(e)),
new Promise(r => setTimeout(() => r('timeout after ' + ms + ' ms'), ms)),
]);
const regs = await navigator.serviceWorker.getRegistrations(); const regs = await navigator.serviceWorker.getRegistrations();
return { registrations: regs.length, scope: regs[0]?.scope || null, state: regs[0]?.active?.state || null, controlling: !!navigator.serviceWorker.controller }; return { ready, registrations: regs.length, state: regs[0]?.active?.state || null };
}); }, SW_READY_TIMEOUT_MS);
const manifest = href ? await page.evaluate(async (h) => { const r = await fetch(h); return { status: r.status, body: await r.json() }; }, href) : { status: 'no link' }; const manifest = href
const m = manifest.body || {}; ? await page.evaluate(async (h) => { const r = await fetch(h); return { status: r.status, body: r.ok ? await r.json() : {} }; }, href)
const icons = (m.icons || []).map(i => i.sizes + (i.purpose ? ':' + i.purpose : '')); : { status: 'no manifest link', body: {} };
const okSw = sw.registrations > 0; const m = manifest.body;
const okIcons = icons.some(s => s.startsWith('192')) && icons.some(s => s.startsWith('512')) && icons.some(s => s.includes('maskable')); const icons = m.icons || [];
const okManifest = m.name === 'MealQuest' && m.display === 'standalone' && !!m.start_url && okIcons; const hasSize = (s) => icons.some(i => (i.sizes || '').split(/\s+/).includes(s));
const pass = okSw && okManifest; const checks = [
['manifest HTTP 200', manifest.status === 200, manifest.status],
['manifest name "MealQuest"', m.name === 'MealQuest', m.name],
['manifest display "standalone"', m.display === 'standalone', m.display],
['manifest start_url present', !!m.start_url, m.start_url],
['icon 192x192', hasSize('192x192'), icons.map(i => i.sizes).join(',')],
['icon 512x512', hasSize('512x512'), icons.map(i => i.sizes).join(',')],
['icon purpose maskable', icons.some(i => (i.purpose || '').split(/\s+/).includes('maskable')), icons.map(i => i.purpose || 'any').join(',')],
['service worker ready', sw.ready === 'ready', sw.ready],
['service worker registered', sw.registrations > 0, sw.registrations],
['service worker activated', sw.state === 'activated', sw.state],
];
console.log(`${name} (${path}) url=${page.url().replace(origin, '')}:`);
for (const [label, ok, actual] of checks) console.log(` ${ok ? 'PASS' : 'FAIL'} ${label} (got ${JSON.stringify(actual ?? null)})`);
const pass = checks.every(([, ok]) => ok);
if (!pass) fail++; if (!pass) fail++;
console.log(`${name}: url=${page.url().replace('https://mealquest.cubecraftlabs.com','')} manifest=${manifest.status} name=${m.name} display=${m.display} icons=[${icons.join(',')}] SW=${JSON.stringify(sw)} => ${pass ? 'INSTALLABLE' : 'NOT INSTALLABLE'}`); console.log(` => ${pass ? 'INSTALLABLE' : 'NOT INSTALLABLE'}`);
await page.close(); await page.close();
} }
await browser.close(); await browser.close();
console.log(fail ? `FAILED on ${fail} of ${pages.length} pages` : `PASS on all ${pages.length} pages`);
process.exit(fail ? 1 : 0); process.exit(fail ? 1 : 0);