From 844f48e9e07726d31b02e8b7cd472fe693dfade0 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Wed, 14 Oct 2026 18:30:06 -0700 Subject: [PATCH] fix(web): avoid service-worker activation deadlock --- web/public/sw.js | 18 +++---------- web/tests/pwa.browser.mjs | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 15 deletions(-) create mode 100644 web/tests/pwa.browser.mjs diff --git a/web/public/sw.js b/web/public/sw.js index e2f33fd..b89ebc7 100644 --- a/web/public/sw.js +++ b/web/public/sw.js @@ -3,21 +3,9 @@ self.addEventListener("install", () => { }); self.addEventListener("activate", (event) => { - event.waitUntil( - (async () => { - await self.clients.claim(); - const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); - await Promise.all( - windows.map(async (client) => { - try { - await client.navigate(client.url); - } catch { - // The client may have closed while the new worker was activating. - } - }) - ); - })() - ); + // Navigations through this worker wait for activation. Awaiting a client + // reload here deadlocks first visits, including the token sign-in screen. + event.waitUntil(self.clients.claim()); }); self.addEventListener("fetch", (event) => { diff --git a/web/tests/pwa.browser.mjs b/web/tests/pwa.browser.mjs new file mode 100644 index 0000000..257e7e8 --- /dev/null +++ b/web/tests/pwa.browser.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import { resolve, extname, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Run after npm run build, with an existing Playwright/Chromium installation: +// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome +// node tests/pwa.browser.mjs +// This serves the real production bundle and service worker on loopback. No live API is used. +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core'); +const root = fileURLToPath(new URL('../dist/', import.meta.url)); +await readFile(resolve(root, 'sw.js')); +const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.ttf': 'font/ttf' }; +const server = createServer(async (request, response) => { + const path = new URL(request.url, 'http://localhost').pathname; + if (path.startsWith('/api/')) { + response.writeHead(401, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + response.end(JSON.stringify({ error: true, message: 'missing bearer token', statusCode: 401 })); + return; + } + const file = resolve(root, '.' + (path === '/' ? '/index.html' : path)); + if (!file.startsWith(root.endsWith(sep) ? root : root + sep)) { response.writeHead(404); response.end(); return; } + try { + const bytes = await readFile(file); + response.writeHead(200, { 'Content-Type': types[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); + response.end(bytes); + } catch { response.writeHead(404); response.end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true, + ...(process.env.BROWSER_NO_SANDBOX === '1' ? { args: ['--no-sandbox'] } : {}) }); +try { + const context = await browser.newContext({ serviceWorkers: 'allow' }); + await context.route('https://fonts.googleapis.com/**', route => route.abort()); + await context.route('https://fonts.gstatic.com/**', route => route.abort()); + const page = await context.newPage(); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + await page.goto(`http://127.0.0.1:${server.address().port}/`, { waitUntil: 'domcontentloaded', timeout: 15_000 }); + // Activation must settle before a worker-controlled navigation can finish. + await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated', undefined, { timeout: 15_000 }); + await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 }); + await page.getByPlaceholder('ADMIN_TOKEN').fill('local-login-fixture'); + assert.equal(await page.getByPlaceholder('ADMIN_TOKEN').inputValue(), 'local-login-fixture'); + assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isEnabled(), true); + await page.reload({ waitUntil: 'domcontentloaded', timeout: 10_000 }); + await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 }); + assert.equal(await page.evaluate(() => navigator.serviceWorker.controller?.state), 'activated'); + assert.deepEqual(errors, []); + console.log('PASS first-visit service-worker activation, stable sign-in form, and controlled reload'); +} finally { + await browser.close(); + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); +}