fix(web): avoid service-worker activation deadlock
This commit is contained in:
+3
-15
@@ -3,21 +3,9 @@ self.addEventListener("install", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("activate", (event) => {
|
self.addEventListener("activate", (event) => {
|
||||||
event.waitUntil(
|
// Navigations through this worker wait for activation. Awaiting a client
|
||||||
(async () => {
|
// reload here deadlocks first visits, including the token sign-in screen.
|
||||||
await self.clients.claim();
|
event.waitUntil(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.
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener("fetch", (event) => {
|
self.addEventListener("fetch", (event) => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user