Automated Testing for Progressive Web Apps¶
Automated PWA testing means proving, on every commit, that your service worker installs, precaches the right files, serves the app offline, hands over cleanly on update, and handles push and sync events, and that your manifest still makes the app installable. None of this is covered by ordinary UI tests. Service workers outlive the page, keep state between runs, intercept the requests your test framework wants to mock, and behave differently in each engine. This page covers the full stack: unit tests for worker logic in Node, end-to-end tests in Playwright across Chromium, Firefox and WebKit, Puppeteer scripts, background events driven through the Chrome DevTools Protocol (CDP), manifest checks, and a CI setup. Every code sample was run against Playwright 1.63, Puppeteer 25.12 and Node 26 in September 2026.
Key takeaways
- Split the worker into pure modules (strategies, routing) and a thin
registerHandlers(scope)function. Then most logic can be unit-tested in Node with small fakes, without a browser. - In Playwright,
context.serviceWorkers(), theserviceworkerevent and routing of requests made by the worker work only in Chromium.response.fromServiceWorker()andserviceWorkers: "block"work in all three engines. context.setOffline(true)cuts off both the page and the worker only in Chromium. In Firefox, the worker's ownfetch()calls still reach the network, and in WebKit every navigation fails, even one the worker answers from cache. To test offline behavior in every engine, make the test server drop connections.serviceWorkers: "block"is an init script that replacesnavigator.serviceWorker.register()with a no-op that resolves toundefined. App code that uses the returned registration will throw.- The CDP
ServiceWorkerdomain dispatchespush,syncandperiodicsyncevents straight to a registration, with no push service involved. Showing notifications also needs the full Chromium build (channel: "chromium"), because the default headless shell reports permission as"denied". - In Puppeteer,
page.setOfflineMode(true)does not affect the worker's ownfetch()calls. Emulate offline on theservice_workertarget's CDP session as well. - Playwright's
browser.newContext()is an off-the-record profile, soPage.getInstallabilityErrorsalways reportsin-incognito. Run installability checks in a persistent context.
What to test at each layer¶
A PWA has more moving parts than a page: a worker script with its own lifecycle, caches that persist, a manifest the browser parses on its own schedule, and events (push, sync) that arrive with no page open. Each layer has a cheapest place to test it:
| Layer | What can break | Cheapest reliable test | Tooling |
|---|---|---|---|
| Routing and caching logic | Wrong strategy for a URL, caching error responses, timeouts that never fire | Unit test in Node with fakes | node:test, Vitest or Jest |
| Event wiring | Install doesn't precache, activate doesn't delete old caches, SKIP_WAITING ignored | Handler test: dispatch fake events on a fake scope | Same as above |
| Registration, control, precache | Scope mismatch, 404 on sw.js, clients.claim() missing, precache list out of date after a build | Browser E2E test | Playwright (all engines) |
| Offline behavior | App shell not cached, offline fallback missing, API fallback wrong | Browser E2E test with the network cut | Playwright, Puppeteer |
| Updates | New worker never activates, update prompt never shows, reload loops | Browser E2E test that serves a changed sw.js | Playwright (all engines) |
| Push, sync, periodic sync | Handler throws, notification payload wrong, outbox not flushed | CDP-dispatched events | Playwright or Puppeteer on Chromium |
| Manifest and installability | Broken JSON, missing icon sizes, start_url outside scope | HTTP-level checks plus Chromium's own installability check | Playwright request, CDP Page.getInstallabilityErrors |
| Performance and page quality | Regressions in LCP, accessibility, best practices | Audits | Lighthouse and Lighthouse CI |
flowchart TD
U["Unit tests: strategies and routing (ms)"] --> H["Handler tests: fake install, activate, fetch, message events"]
H --> E["Playwright E2E: registration, offline, updates (seconds)"]
E --> C["CDP-driven tests: push, sync, periodic sync (Chromium)"]
E --> M["Manifest and installability checks"]
C --> A["Lighthouse CI: performance and quality budgets"]
M --> A Keep the fast layers large and the slow layers focused. A browser test that checks every cache route costs seconds and flakes. A unit test that checks every route costs milliseconds. The browser tests should prove that the pieces are wired together in a real engine.
Unit testing service worker logic¶
Designing a worker you can test¶
A service worker written as one file full of self.addEventListener calls and references to the global caches can only run inside a browser. Two refactorings make almost all of it testable in Node:
- Move decisions into pure functions that take their dependencies (
cacheStorage,fetchImpl, a cache name) as arguments. Node 18 and later shipRequest,Response,Headers,fetchandAbortController, so these functions run unchanged. - Wrap event registration in a function that takes the scope. In production you pass
self. In tests you pass anEventTargetwith fakes attached.
// Pure caching logic. No reference to `self` or the global `caches`, so it
// runs unchanged in a service worker, in Node and in a browser test runner.
/**
* Network-first with a timeout, falling back to the cache.
* @param {Request} request
* @param {object} deps
* @param {CacheStorage} deps.cacheStorage
* @param {typeof fetch} deps.fetchImpl
* @param {string} deps.cacheName
* @param {number} [deps.timeoutMs]
* @returns {Promise<Response>}
*/
export async function networkFirst(request, { cacheStorage, fetchImpl, cacheName, timeoutMs = 3000 }) {
const cache = await cacheStorage.open(cacheName);
const controller = new AbortController();
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => {
controller.abort(); // (1)!
reject(new Error('network-timeout'));
}, timeoutMs);
});
try {
const response = await Promise.race([
fetchImpl(request, { signal: controller.signal }),
timeout,
]);
// Cache only successful responses we can read. "default" is the type of
// a constructed Response (unit tests); real fetches are basic or cors.
if (response.ok && ['basic', 'cors', 'default'].includes(response.type)) {
await cache.put(request, response.clone()); // (2)!
}
return response;
} catch (error) {
const cached = await cache.match(request);
if (cached) return cached;
throw error;
} finally {
clearTimeout(timer);
}
}
/**
* Decide which strategy a request gets. Returning null means "let the
* browser handle it" (no respondWith call).
* @param {Request} request
* @param {string} origin the service worker's origin
*/
export function routeFor(request, origin) {
if (request.method !== 'GET') return null;
const url = new URL(request.url);
if (url.origin !== origin) return null;
if (request.mode === 'navigate') return 'page';
if (url.pathname.startsWith('/api/')) return 'api';
if (/\.(?:js|css|woff2|png|svg|webp)$/.test(url.pathname)) return 'static';
return null;
}
- Aborting the losing
fetch()matters in a real worker: an abandoned request keeps a connection busy and, withevent.waitUntil(), can keep the worker alive. cache.put()consumes the body it stores, so store a clone and return the original. The unit tests below check this indirectly: the fake cache rejects a used body, as the real one does.
import { networkFirst, routeFor } from './strategies.js';
export const VERSION = '2026-09-25';
export const PRECACHE = `precache-${VERSION}`;
export const RUNTIME = 'runtime';
export const PRECACHE_URLS = ['/', '/offline.html', '/app.css', '/app.js'];
/**
* Wires every event listener onto `scope`. In production `scope` is `self`;
* in unit tests it is a plain EventTarget with fakes attached.
* @param {ServiceWorkerGlobalScope} scope
*/
export function registerHandlers(scope) {
scope.addEventListener('install', (event) => {
event.waitUntil(
scope.caches.open(PRECACHE).then((cache) => cache.addAll(PRECACHE_URLS)),
);
});
scope.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keep = new Set([PRECACHE, RUNTIME]);
for (const key of await scope.caches.keys()) {
if (!keep.has(key)) await scope.caches.delete(key);
}
await scope.clients.claim();
})());
});
scope.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') scope.skipWaiting();
});
scope.addEventListener('fetch', (event) => {
const route = routeFor(event.request, scope.location.origin);
if (route === null) return; // fall through to the network
// Bind fetch: calling it as a method of `deps` would throw "Illegal invocation".
const deps = { cacheStorage: scope.caches, fetchImpl: scope.fetch.bind(scope), cacheName: RUNTIME };
if (route === 'page') {
event.respondWith(
networkFirst(event.request, { ...deps, timeoutMs: 4000 })
.catch(async () => (await scope.caches.match('/offline.html')) ?? Response.error()),
);
} else if (route === 'api') {
event.respondWith(networkFirst(event.request, { ...deps, timeoutMs: 5000 }));
} else {
event.respondWith(
scope.caches.match(event.request).then((hit) => hit ?? scope.fetch(event.request)),
);
}
});
}
// Module service worker entry point: register with { type: 'module' }.
import { registerHandlers } from './handlers.js';
registerHandlers(self);
The entry file is registered with navigator.serviceWorker.register('/sw.js', { type: 'module' }), or your bundler inlines the imports into a classic script. Module service workers work in Chrome and Edge 91+, Safari 15+ and Firefox 147+. Only static import is allowed, because import() is not allowed in service workers. See Registration & Scope for a module-with-classic-fallback pattern, and Workbox if you would rather not write strategies yourself. Workbox's strategies are already tested upstream, so your tests only need to cover the routing configuration.
Fakes for the service worker global scope¶
You can't use the platform's own event classes to drive the handlers from a test. The spec's add lifetime promise steps start with "If event's isTrusted attribute is false, throw an InvalidStateError". Any ExtendableEvent created by script is untrusted, so calling waitUntil() on it throws, even in a real browser. Node doesn't have ExtendableEvent, FetchEvent or CacheStorage at all. Small fakes that mirror the parts you use are enough:
// Minimal, spec-shaped fakes for the parts of the service worker global
// scope the handlers use. Keep them small: they exist to make assertions
// possible, not to re-implement the platform.
export class FakeCache {
#entries = new Map(); // url -> Response
async match(request) {
const url = typeof request === 'string' ? new URL(request, 'https://app.test').href : request.url;
return this.#entries.get(url)?.clone();
}
async put(request, response) {
const url = typeof request === 'string' ? new URL(request, 'https://app.test').href : request.url;
// Mirror Cache.put(): a used body cannot be stored.
if (response.bodyUsed) throw new TypeError('Response body is already used');
this.#entries.set(url, response);
}
async addAll(urls) {
for (const u of urls) this.#entries.set(new URL(u, 'https://app.test').href, new Response(`precached ${u}`));
}
async keys() { return [...this.#entries.keys()].map((u) => new Request(u)); }
}
export class FakeCacheStorage {
#caches = new Map();
async open(name) {
if (!this.#caches.has(name)) this.#caches.set(name, new FakeCache());
return this.#caches.get(name);
}
async has(name) { return this.#caches.has(name); }
async delete(name) { return this.#caches.delete(name); }
async keys() { return [...this.#caches.keys()]; }
async match(request) {
for (const cache of this.#caches.values()) {
const hit = await cache.match(request);
if (hit) return hit;
}
return undefined;
}
}
export class FakeExtendableEvent extends Event {
#promises = [];
waitUntil(promise) { this.#promises.push(Promise.resolve(promise)); }
// Resolves when every waitUntil() promise settles; rejects like the
// browser would treat a failed install.
settled() { return Promise.all(this.#promises); }
}
export class FakeFetchEvent extends FakeExtendableEvent {
#response = null;
constructor(request) { super('fetch'); this.request = request; }
respondWith(promise) {
if (this.#response) throw new DOMException('respondWith() already called', 'InvalidStateError');
this.#response = Promise.resolve(promise);
}
get handled() { return this.#response !== null; }
response() { return this.#response; }
}
export class FakeMessageEvent extends FakeExtendableEvent {
constructor(data) { super('message'); this.data = data; }
}
/** Builds an EventTarget that looks enough like ServiceWorkerGlobalScope. */
export function createScope({ fetchImpl } = {}) {
const scope = new EventTarget();
scope.caches = new FakeCacheStorage();
scope.location = new URL('https://app.test/sw.js');
scope.fetch = fetchImpl ?? (async () => { throw new TypeError('Failed to fetch'); });
scope.clients = { claimed: false, async claim() { this.claimed = true; } };
scope.skipWaitingCalls = 0;
scope.skipWaiting = async () => { scope.skipWaitingCalls += 1; };
return scope;
}
/** Builds a navigation Request; Node's Request refuses mode: 'navigate'. */
export function navigationRequest(url) {
const request = new Request(url);
Object.defineProperty(request, 'mode', { value: 'navigate' });
return request;
}
Two details in these fakes come from the Fetch standard, not from convenience:
new Request(url, { mode: 'navigate' })throws aTypeErrorin every implementation, Node's included, because only the browser can create navigation requests. Defining the property on an instance is the least invasive workaround.Cache.match()returns a newResponseeach time. The fake clones on read, so a test that reads a cached body twice behaves as it would in a browser.
Unit tests with node:test¶
Node's built-in runner needs no dependencies. Its mock.timers API (available since Node 20.4) lets you test timeouts without waiting for them:
import { test, describe, mock } from 'node:test';
import assert from 'node:assert/strict';
import { networkFirst, routeFor } from '../src/sw/strategies.js';
import { FakeCacheStorage } from './fakes.js';
const ORIGIN = 'https://app.test';
describe('routeFor', () => {
test('ignores non-GET and cross-origin requests', () => {
assert.equal(routeFor(new Request(`${ORIGIN}/api/x`, { method: 'POST' }), ORIGIN), null);
assert.equal(routeFor(new Request('https://cdn.example/lib.js'), ORIGIN), null);
});
test('classifies API and static requests', () => {
assert.equal(routeFor(new Request(`${ORIGIN}/api/items`), ORIGIN), 'api');
assert.equal(routeFor(new Request(`${ORIGIN}/assets/app.3f9a.js`), ORIGIN), 'static');
});
});
describe('networkFirst', () => {
test('stores successful network responses', async () => {
const cacheStorage = new FakeCacheStorage();
const fetchImpl = mock.fn(async () => new Response('fresh', { status: 200 }));
const res = await networkFirst(new Request(`${ORIGIN}/api/items`), { cacheStorage, fetchImpl, cacheName: 'rt' });
assert.equal(await res.text(), 'fresh');
const cached = await (await cacheStorage.open('rt')).match(new Request(`${ORIGIN}/api/items`));
assert.equal(await cached.text(), 'fresh');
assert.equal(fetchImpl.mock.callCount(), 1);
});
test('does not cache error responses', async () => {
const cacheStorage = new FakeCacheStorage();
const fetchImpl = async () => new Response('nope', { status: 503 });
const res = await networkFirst(new Request(`${ORIGIN}/api/items`), { cacheStorage, fetchImpl, cacheName: 'rt' });
assert.equal(res.status, 503);
assert.equal(await (await cacheStorage.open('rt')).match(new Request(`${ORIGIN}/api/items`)), undefined);
});
test('falls back to the cache when the network fails', async () => {
const cacheStorage = new FakeCacheStorage();
await (await cacheStorage.open('rt')).put(new Request(`${ORIGIN}/api/items`), new Response('stale'));
const fetchImpl = async () => { throw new TypeError('Failed to fetch'); };
const res = await networkFirst(new Request(`${ORIGIN}/api/items`), { cacheStorage, fetchImpl, cacheName: 'rt' });
assert.equal(await res.text(), 'stale');
});
test('times out slow networks using fake timers', async () => {
mock.timers.enable({ apis: ['setTimeout'] });
try {
const cacheStorage = new FakeCacheStorage();
await (await cacheStorage.open('rt')).put(new Request(`${ORIGIN}/api/items`), new Response('stale'));
let signal;
const fetchImpl = (_req, init) => { signal = init.signal; return new Promise(() => {}); }; // never settles
const pending = networkFirst(new Request(`${ORIGIN}/api/items`), { cacheStorage, fetchImpl, cacheName: 'rt', timeoutMs: 3000 });
// Let networkFirst reach its await on Promise.race before advancing time.
await new Promise((resolve) => setImmediate(resolve));
mock.timers.tick(3000);
const res = await pending;
assert.equal(await res.text(), 'stale');
assert.equal(signal.aborted, true, 'the in-flight fetch must be aborted');
} finally {
mock.timers.reset();
}
});
test('rethrows when there is no cached copy', async () => {
const cacheStorage = new FakeCacheStorage();
const fetchImpl = async () => { throw new TypeError('Failed to fetch'); };
await assert.rejects(
networkFirst(new Request(`${ORIGIN}/api/items`), { cacheStorage, fetchImpl, cacheName: 'rt' }),
TypeError,
);
});
});
Handler tests dispatch fake lifecycle events and await everything passed to waitUntil(). This is the same contract the browser enforces, so a handler that forgets waitUntil() fails here too:
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { registerHandlers, PRECACHE, PRECACHE_URLS } from '../src/sw/handlers.js';
import {
createScope, FakeExtendableEvent, FakeFetchEvent, FakeMessageEvent, navigationRequest,
} from './fakes.js';
async function installAndActivate(scope) {
const install = new FakeExtendableEvent('install');
scope.dispatchEvent(install);
await install.settled();
const activate = new FakeExtendableEvent('activate');
scope.dispatchEvent(activate);
await activate.settled();
}
describe('service worker handlers', () => {
test('install precaches the app shell', async () => {
const scope = createScope();
registerHandlers(scope);
const install = new FakeExtendableEvent('install');
scope.dispatchEvent(install);
await install.settled();
const cache = await scope.caches.open(PRECACHE);
assert.equal((await cache.keys()).length, PRECACHE_URLS.length);
});
test('activate deletes old caches and claims clients', async () => {
const scope = createScope();
registerHandlers(scope);
await scope.caches.open('precache-2026-01-01');
await installAndActivate(scope);
assert.deepEqual((await scope.caches.keys()).sort(), [PRECACHE]);
assert.equal(scope.clients.claimed, true);
});
test('offline navigations get the offline page', async () => {
const scope = createScope(); // default fetch always rejects
registerHandlers(scope);
await installAndActivate(scope);
const event = new FakeFetchEvent(navigationRequest('https://app.test/settings'));
scope.dispatchEvent(event);
assert.equal(event.handled, true);
const res = await event.response();
assert.equal(await res.text(), 'precached /offline.html');
});
test('cross-origin requests are left to the browser', () => {
const scope = createScope();
registerHandlers(scope);
const event = new FakeFetchEvent(new Request('https://fonts.example/x.woff2'));
scope.dispatchEvent(event);
assert.equal(event.handled, false);
});
test('SKIP_WAITING message calls skipWaiting()', () => {
const scope = createScope();
registerHandlers(scope);
scope.dispatchEvent(new FakeMessageEvent({ type: 'SKIP_WAITING' }));
scope.dispatchEvent(new FakeMessageEvent({ type: 'SOMETHING_ELSE' }));
assert.equal(scope.skipWaitingCalls, 1);
});
});
Run everything with node --test 'test/**/*.test.js' (add "type": "module" to package.json). Glob arguments need Node 21 or later. Without an argument, Node's default patterns also pick up every .js file under a test/ directory, including fakes.js, which is harmless but noisy. The 12 tests above finish in under 200 ms on Node 26. The same files run under Vitest if you replace the node:test imports with Vitest's describe/test/vi and mock.timers with vi.useFakeTimers().
What unit tests cannot tell you¶
Fakes encode your assumptions about the platform, so they can't catch the bugs that come from wrong assumptions:
- Lifecycle timing. When the worker actually reaches
activated, whetherclients.claim()firescontrollerchangein an open tab, and what happens to a page that was loaded before the worker existed (Lifecycle). - Real Cache Storage semantics.
Varymatching,ignoreSearch, opaque responses that take up padded quota (Cache Storage API, Storage Quotas). - Scope and headers. A
sw.jsserved from the wrong path, with the wrong MIME type, or cached by a CDN (Registration & Scope). - Engine differences. Navigation preload, static routing and module workers each have their own support matrix.
Browser tests cover these gaps.
End-to-end testing with Playwright¶
How Playwright exposes service workers¶
Playwright added the serviceWorkers context option in version 1.23. Since 1.57, it reports network requests issued by service workers and routes them through the BrowserContext, and forwards worker console messages to worker.on('console'), in Chromium only. You can opt out with the PLAYWRIGHT_DISABLE_SERVICE_WORKER_NETWORK and PLAYWRIGHT_DISABLE_SERVICE_WORKER_CONSOLE environment variables. Playwright's documentation states flatly that "service workers are only supported on Chromium-based browsers". In practice, that means the inspection APIs. The page's own worker runs normally in Firefox and WebKit, and several APIs work in all three engines, as the next table shows.
| API | What it does | Chromium | Firefox | WebKit |
|---|---|---|---|---|
browser.newContext({ serviceWorkers: 'allow' \| 'block' }) | 'block' injects an init script that stubs navigator.serviceWorker.register(). Default 'allow' | ✅ | ✅ | ✅ |
context.serviceWorkers() | Array of Worker handles for the context's service workers | ✅ | ❌ (empty) | ❌ (empty) |
context.on('serviceworker') / waitForEvent | Fires when a service worker is created, before it controls a page | ✅ | ❌ | ❌ |
worker.evaluate() / worker.url() | Run code in the worker's global scope | ✅ | ❌ | ❌ |
worker.on('console') | Console messages from the worker (1.57+) | ✅ | ❌ | ❌ |
request.serviceWorker() | The Worker that issued a request, or null | ✅ | ❌ | ❌ |
context.route() for requests made by the worker | Mock or block the worker's own fetch() calls and precache downloads | ✅ | ❌ | ❌ |
response.fromServiceWorker() | true when a page request was answered by a fetch handler | ✅ | ✅ | ✅ |
context.setOffline(true) with a controlling worker | Network cut for page and worker; worker-served navigations succeed | ✅ | ⚠️ | ❌ |
Support data as of September 2026, verified with Playwright 1.63 (Chromium 153, Firefox 155, WebKit 26.6). In Firefox, setOffline(true) sets navigator.onLine to false and fails uncontrolled navigations with NS_ERROR_OFFLINE, but navigations the worker answers succeed and the worker's own fetch() calls still reach the server, so a network-first handler returns live data instead of its fallback. In WebKit, every navigation fails with "WebKit encountered an internal error", including one answered from Cache Storage or with a constructed Response, which is tracked as microsoft/playwright#42775. Check the Playwright service worker guide for changes.
Project setup: a server per worker and shared fixtures¶
Test against your production build, served the way production serves it. Development servers often don't register a worker at all. vite-plugin-pwa, for example, disables it in development unless you set devOptions.enabled (Vite PWA Plugin). A tiny Node server gives you two controls that make PWA tests deterministic: it can drop every connection to simulate a dead network, and it can rewrite a file on the fly to ship a "new" service worker.
// A tiny static server for the production build, with test-only controls.
// Each Playwright worker starts its own instance on a random port, so tests
// that take the "network" down never affect each other.
import http from 'node:http';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
const TYPES = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.webmanifest': 'application/manifest+json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.woff2': 'font/woff2',
};
/**
* @param {{ root: string, port?: number }} options
*/
export async function startServer({ root, port = 0 }) {
const rootDir = path.resolve(root);
let networkDown = false;
/** @type {Map<string, (body: string) => string>} */
const patches = new Map();
/** @type {Array<{ method: string, path: string, serviceWorker: boolean }>} */
const log = [];
const server = http.createServer(async (req, res) => {
// Simulate a dead network: drop the TCP connection without a response.
// Browsers surface this as a network error (TypeError in fetch()).
if (networkDown) {
req.socket.destroy();
return;
}
const url = new URL(req.url, 'http://localhost');
log.push({
method: req.method,
path: url.pathname,
// The Service Workers spec adds "Service-Worker: script" to script fetches.
serviceWorker: req.headers['service-worker'] === 'script',
});
if (url.pathname.startsWith('/api/')) {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ path: url.pathname, at: Date.now() }));
return;
}
const file = url.pathname.endsWith('/') ? `${url.pathname}index.html` : url.pathname;
const fullPath = path.join(rootDir, path.normalize(file));
// Compare with a trailing separator so "/srv/dist-old" can't pass as "/srv/dist".
if (!fullPath.startsWith(rootDir + path.sep)) {
res.writeHead(403).end();
return;
}
try {
let body = await readFile(fullPath);
const patch = patches.get(url.pathname);
if (patch) body = Buffer.from(patch(body.toString('utf8')));
res.setHeader('content-type', TYPES[path.extname(fullPath)] ?? 'application/octet-stream');
// The SW script must never be served from the HTTP cache in tests.
if (url.pathname === '/sw.js') res.setHeader('cache-control', 'no-cache');
res.end(body);
} catch {
res.writeHead(404, { 'content-type': 'text/plain' }).end('Not found');
}
});
// No host argument: listen on all interfaces so "localhost" works over IPv4 and IPv6.
await new Promise((resolve) => server.listen(port, resolve));
const { port: actualPort } = /** @type {import('node:net').AddressInfo} */ (server.address());
return {
// Use "localhost", not 127.0.0.1: both are secure contexts, but they are
// different origins, and the SW registration belongs to one of them.
url: `http://localhost:${actualPort}`,
log,
setNetworkDown(value) { networkDown = value; },
/** Rewrite a file on the fly, e.g. to ship a "new" service worker. */
patch(pathname, transform) { patches.set(pathname, transform); },
reset() { networkDown = false; patches.clear(); log.length = 0; },
close() {
server.closeAllConnections();
return new Promise((resolve) => server.close(resolve));
},
};
}
The fixtures file starts one server per Playwright worker process, points baseURL at it, resets it after every test, and provides a page that is already controlled:
import { test as base, expect } from '@playwright/test';
import { startServer } from './server.mjs';
/**
* Resolves once the page is controlled by an *activated* service worker.
* navigator.serviceWorker.ready resolves when the registration has an active
* worker, which can happen before clients.claim() makes it the controller,
* so check the controller too.
* @param {import('@playwright/test').Page} page
*/
export async function waitForServiceWorkerControl(page, { timeout = 10_000 } = {}) {
await page.waitForFunction(async () => {
if (!('serviceWorker' in navigator)) throw new Error('No service worker support');
const registration = await navigator.serviceWorker.ready;
return registration.active?.state === 'activated' && navigator.serviceWorker.controller !== null;
}, undefined, { timeout, polling: 100 });
}
export const test = base.extend({
// One server per worker process; torn down when the worker exits.
app: [async ({}, use) => {
const app = await startServer({ root: process.env.DIST_DIR ?? 'dist' });
await use(app);
await app.close();
}, { scope: 'worker' }],
// Point relative page.goto('/') calls at this worker's server.
baseURL: async ({ app }, use) => {
await use(app.url);
},
// Restore the server after every test, even when the test fails midway.
resetApp: [async ({ app }, use) => {
await use();
app.reset();
}, { auto: true }],
// A page that has loaded "/" and is already under SW control.
controlledPage: async ({ page }, use) => {
await page.goto('/');
await waitForServiceWorkerControl(page);
await use(page);
},
});
export { expect };
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
// Tests are isolated per worker (own server, own port), so parallel is safe.
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 2 : undefined,
reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : 'list',
use: {
trace: 'retain-on-failure',
// Explicit, even though 'allow' is the default: these tests are about SWs.
serviceWorkers: 'allow',
},
projects: [
// channel: 'chromium' runs the full browser in new headless mode instead of
// the headless shell, which is required for notification tests (see below).
{ name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chromium' } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
Every test gets a fresh BrowserContext, so registrations and caches never leak between tests. That is the main reason to prefer the default isolated contexts over a shared persistent profile, except for the installability check shown later.
The app under test is a minimal but realistic one. Its worker precaches a shell, serves navigations network-first with an offline fallback, answers /api/ with a JSON error when offline, and handles push and sync:
const VERSION = 'v1';
const PRECACHE = `precache-${VERSION}`;
const RUNTIME = 'runtime';
const PRECACHE_URLS = ['/', '/offline.html', '/app.js', '/manifest.webmanifest'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(PRECACHE).then((c) => c.addAll(PRECACHE_URLS)));
});
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
for (const k of await caches.keys()) if (k !== PRECACHE && k !== RUNTIME) await caches.delete(k);
await self.clients.claim();
})());
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') self.skipWaiting();
if (event.data?.type === 'GET_VERSION') event.source?.postMessage({ type: 'VERSION', version: VERSION });
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== location.origin) return;
if (request.mode === 'navigate') {
event.respondWith((async () => {
try { return await fetch(request); }
catch {
return (await caches.match(request, { ignoreSearch: true }))
?? (await caches.match('/offline.html'));
}
})());
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith((async () => {
try {
const res = await fetch(request);
if (res.ok) {
// Clone synchronously, before the page starts reading the body, and
// keep the worker alive until the write finishes.
const copy = res.clone();
event.waitUntil(caches.open(RUNTIME).then((c) => c.put(request, copy)));
}
return res;
} catch {
return (await caches.match(request)) ?? Response.json({ error: 'offline' }, { status: 503 });
}
})());
return;
}
event.respondWith(caches.match(request).then((hit) => hit ?? fetch(request)));
});
self.addEventListener('push', (event) => {
let payload = {};
try {
payload = event.data?.json() ?? {};
} catch {
// Non-JSON payload: show it as the body instead of throwing.
payload = { body: event.data?.text() ?? '' };
}
event.waitUntil(self.registration.showNotification(payload.title ?? 'Update', {
body: payload.body ?? '',
tag: payload.tag ?? 'default',
data: { url: payload.url ?? '/' },
}));
});
self.addEventListener('sync', (event) => {
if (event.tag === 'outbox') event.waitUntil(fetch('/api/outbox/flush', { method: 'POST' }));
});
And the page script registers it and implements a prompt-to-update flow (Updating Service Workers explains the pattern):
const reloadButton = document.getElementById('reload');
if ('serviceWorker' in navigator) {
// Only reload on controllerchange if a worker was already in charge.
// Without this guard, clients.claim() on the very first install reloads the page.
const hadController = navigator.serviceWorker.controller !== null;
const reg = await navigator.serviceWorker.register('/sw.js');
if (reg) { // undefined when Playwright blocks service workers
const showUpdate = () => { reloadButton.hidden = false; };
if (reg.waiting && hadController) showUpdate();
reg.addEventListener('updatefound', () => {
const w = reg.installing;
w?.addEventListener('statechange', () => {
if (w.state === 'installed' && navigator.serviceWorker.controller) showUpdate();
});
});
reloadButton.addEventListener('click', () => reg.waiting?.postMessage({ type: 'SKIP_WAITING' }));
let reloading = false;
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!hadController || reloading) return;
reloading = true;
location.reload();
});
}
}
app.js uses top-level await, so the page loads it as a module. The remaining files are what the specs below assert on: the headings "Demo app" and "You are offline", the button whose accessible name matches /update available/i, and a manifest with 192 px, 512 px and maskable icons.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0b57d0">
<title>Demo app</title>
<link rel="manifest" href="/manifest.webmanifest">
<!-- type="module": app.js uses top-level await. -->
<script type="module" src="/app.js"></script>
</head>
<body>
<h1>Demo app</h1>
<!-- Hidden until a new service worker is waiting. -->
<button id="reload" type="button" hidden>Update available: reload</button>
</body>
</html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline - Demo app</title>
</head>
<body>
<h1>You are offline</h1>
<p>This page isn't available offline. <a href="/">Go to the start page</a>.</p>
</body>
</html>
{
"id": "/",
"name": "Demo app",
"short_name": "Demo",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#0b57d0",
"icons": [
{ "src": "/icons/192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
Note that offline.html doesn't load app.js. A fallback page that registers the worker again, or that depends on scripts the worker hasn't cached, is a common source of offline bugs.
The hadController guard exists because the first version of this suite failed. Without it, clients.claim() on first install fired controllerchange, the page reloaded itself, and every test that called page.evaluate() right after goto() failed with "Execution context was destroyed". Real users hit the same bug as an unexplained reload on their first visit. This is the kind of bug that end-to-end tests catch and unit tests can't.
Waiting for activation and control¶
Three different conditions are easy to confuse, and each test needs a specific one:
| Condition | How to detect it | Typical use |
|---|---|---|
| A worker exists (installing) | context.waitForEvent('serviceworker') (Chromium) | Get a Worker handle early, for example to collect its console output |
| The registration has an active worker | await navigator.serviceWorker.ready | Worker-level assertions (caches, evaluate) |
| The page is controlled | navigator.serviceWorker.controller !== null, or a controllerchange event | Anything that expects page requests to go through the fetch handler |
The serviceworker event fires when the worker is created, before it has installed, so a Worker from that event may still be installing. Playwright's own guide recommends waiting for activation in the page before calling worker.evaluate(). A page loaded before the worker existed stays uncontrolled until clients.claim() runs. If your worker doesn't claim, reload the page instead. waitForServiceWorkerControl() covers both: it polls until the registration is activated and the page has a controller.
Registration, precache and worker-scope tests¶
import { test, expect, waitForServiceWorkerControl } from './support/fixtures.mjs';
test.describe('registration and precache', () => {
test('registers, activates and controls the page', async ({ page, app }) => {
await page.goto('/');
await waitForServiceWorkerControl(page);
const info = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
return {
scope: reg.scope,
script: navigator.serviceWorker.controller.scriptURL,
state: reg.active.state,
};
});
expect(info).toEqual({ scope: `${app.url}/`, script: `${app.url}/sw.js`, state: 'activated' });
// The browser marks the SW script request with "Service-Worker: script".
expect(app.log).toContainEqual({ method: 'GET', path: '/sw.js', serviceWorker: true });
});
test('precaches the app shell', async ({ controlledPage: page }) => {
// Cache Storage is exposed to windows too, so this works in every engine.
const cached = await page.evaluate(async () => {
const names = await caches.keys();
const precache = names.find((n) => n.startsWith('precache-'));
const requests = await (await caches.open(precache)).keys();
return requests.map((r) => new URL(r.url).pathname).sort();
});
expect(cached).toEqual(['/', '/app.js', '/manifest.webmanifest', '/offline.html']);
});
test('can evaluate inside the service worker (Chromium only)', async ({ context, page, browserName }) => {
test.skip(browserName !== 'chromium', 'context.serviceWorkers() is Chromium-only');
// Start listening before navigation: the event fires while the SW is still installing.
const swPromise = context.waitForEvent('serviceworker');
await page.goto('/');
const worker = await swPromise;
await waitForServiceWorkerControl(page);
expect(context.serviceWorkers()).toContain(worker);
const scope = await worker.evaluate(() => ({
state: self.serviceWorker.state,
scope: self.registration.scope,
}));
expect(scope.state).toBe('activated');
});
});
The Service-Worker: script assertion is useful in its own right. The header is added by the spec's fetch hook for worker scripts, and all three engines sent it in these tests. Your server or CDN can key on it to set Cache-Control: no-cache for worker scripts only, and the test proves the header arrives.
Precache assertions run in the page, because caches is available in window contexts. That keeps them cross-engine. If you use a generated precache manifest (Workbox's __WB_MANIFEST, see Precaching), assert on a few critical entries and on the absence of large files that shouldn't be there, rather than on the exact list, which changes with every build.
Three ways to take a PWA offline in tests¶
"Offline" can mean three different things in a test, and the engines disagree about the first two:
| Technique | What it cuts | navigator.onLine | Chromium | Firefox | WebKit |
|---|---|---|---|---|---|
context.setOffline(true) (or offline: true in context options) | All network access for pages and (Chromium) the worker | false | ✅ SW-served navigation works, worker fetch() rejects | ⚠️ SW-served navigation works, but the worker's fetch() reaches the server; uncontrolled navigations fail with NS_ERROR_OFFLINE | ❌ every navigation fails with "WebKit encountered an internal error"; page fetch() rejects with "Load failed" |
context.route('**/*', r => r.abort('internetdisconnected')) | Requests Playwright can route | true | ✅ worker fetch() calls are aborted too | ⚠️ navigation served by SW, but the worker's fetch() reaches the network | ❌ reload fails ("Blocked by Web Inspector"); worker fetch() reaches the network |
Test server drops connections (app.setNetworkDown(true)) | Everything that talks to your origin | true | ✅ | ✅ | ✅ |
Verified with Playwright 1.63 in September 2026. offline: true at context creation is worse: the very first navigation fails in all three engines before a worker can exist, so use it only for "first visit while offline" tests.
The server-side approach is the only one that behaves the same in all three engines. It is also the most realistic model of the failure your offline code handles most often: a request that fails at the network level, which your worker sees as a rejected fetch() promise. Its limitation is navigator.onLine, which stays true. If your UI branches on navigator.onLine or the online/offline events, cover that with the Chromium-only setOffline test. The Offline UX page explains why navigator.onLine is a weak signal anyway.
test.describe('offline behavior', () => {
test('serves cached pages when the server is unreachable (all engines)', async ({ controlledPage: page, app }) => {
app.setNetworkDown(true);
await page.reload();
await expect(page.getByRole('heading', { name: 'Demo app' })).toBeVisible();
// An uncached URL falls back to the offline page.
await page.goto('/reports/2026');
await expect(page.getByRole('heading', { name: 'You are offline' })).toBeVisible();
// API calls get the SW's JSON fallback instead of a network error.
const api = await page.evaluate(async () => {
const res = await fetch('/api/items');
return { status: res.status, body: await res.json() };
});
expect(api).toEqual({ status: 503, body: { error: 'offline' } });
});
test('context.setOffline() also cuts off the service worker (Chromium)', async ({ context, controlledPage: page, browserName }) => {
test.skip(browserName !== 'chromium', 'setOffline leaves the worker online in Firefox and breaks SW-served navigations in WebKit');
await context.setOffline(true);
await page.reload();
await expect(page.getByRole('heading', { name: 'Demo app' })).toBeVisible();
expect(await page.evaluate(() => navigator.onLine)).toBe(false);
const status = await page.evaluate(() => fetch('/api/items').then((r) => r.status));
expect(status).toBe(503);
await context.setOffline(false);
});
});
Offline tests must start from a controlled page
A test that goes offline before the worker has installed and claimed the page only proves that the browser shows its own error page. Always wait for control first. For "first visit while offline", assert the failure explicitly.
Routing and service workers: who sees which request¶
Playwright's network interception uses the browser's own interception hooks, and a controlling service worker sits between the page and those hooks. The rules, from Playwright's documentation and confirmed in the tests above:
- A page request answered by the worker's
fetchhandler never reaches the network, sopage.route()andcontext.route()don't see it. Playwright still emitsrequestandresponseevents for it, andresponse.fromServiceWorker()returnstrue. - In Chromium (1.57+), requests the worker itself makes (the worker script,
cache.addAll()downloads during install, andfetch()calls inside handlers) appear asBrowserContextevents, can be routed withcontext.route(), and return theWorkerfromrequest.serviceWorker().request.frame()throws for these requests, so guard any shared route handler. page.route()never sees worker requests. They don't belong to a page.- Requests for an updated worker script can't be routed. Playwright's service worker guide states this limitation, and microsoft/playwright#14711 is the question that led users to the workaround: change what the server returns, as the update test below does.
sequenceDiagram
participant Page
participant SW as Service worker
participant PW as Playwright route
participant Net as Server
Page->>SW: fetch("/api/items")
Note over Page,SW: page.route() does not see this request
SW->>PW: fetch(request) from the worker (Chromium only)
PW-->>SW: route.fulfill() or route.continue()
PW->>Net: continue
SW-->>Page: Response (fromServiceWorker() is true) In Chromium this gives you a precise way to mock APIs through the worker, which tests the worker's caching of the mocked response as well:
test.describe('network interception', () => {
test('context.route() sees requests made by the service worker (Chromium)', async ({ context, controlledPage: page, browserName }) => {
test.skip(browserName !== 'chromium', 'SW network events are only reported in Chromium');
const seen = [];
await context.route('**/api/**', async (route) => {
const request = route.request();
seen.push({ url: new URL(request.url()).pathname, fromSW: request.serviceWorker() !== null });
await route.fulfill({ json: [{ id: 1, name: 'Mocked item' }] });
});
const body = await page.evaluate(() => fetch('/api/items').then((r) => r.json()));
expect(body).toEqual([{ id: 1, name: 'Mocked item' }]);
// The page's fetch was answered by the SW; only the SW's own fetch hit the route.
expect(seen).toEqual([{ url: '/api/items', fromSW: true }]);
});
test('response.fromServiceWorker() identifies SW-served responses', async ({ controlledPage: page }) => {
const [response] = await Promise.all([
page.waitForResponse((r) => r.url().endsWith('/app.js')),
page.evaluate(() => fetch('/app.js')),
]);
expect(response.fromServiceWorker()).toBe(true);
});
});
For the rest of your UI suite, where the worker is only in the way of page.route() mocks, block it. serviceWorkers: 'block' is simple: Playwright adds an init script that replaces navigator.serviceWorker.register with an async function that logs "Service Worker registration blocked by Playwright" and resolves to undefined. Three consequences:
'serviceWorker' in navigatoris stilltrue, so feature detection passes.register()resolves toundefinedinstead of aServiceWorkerRegistration. Code such asreg.addEventListener('updatefound', …)throws aTypeErrorin all three engines. Guard withif (reg), asapp.jsdoes, or the blocked suite fails for a reason unrelated to what it tests.navigator.serviceWorker.readynever resolves. Any code that awaits it hangs.
Blocking only stops new registrations made through register(). In a persistent context that already has a registration from an earlier run, the old worker keeps controlling pages.
test.describe('without a service worker', () => {
test.use({ serviceWorkers: 'block' });
test('page.route() mocks work when SWs are blocked', async ({ page }) => {
await page.route('**/api/items', (route) => route.fulfill({ json: { mocked: true } }));
await page.goto('/');
const body = await page.evaluate(() => fetch('/api/items').then((r) => r.json()));
expect(body).toEqual({ mocked: true });
expect(await page.evaluate(() => navigator.serviceWorker.controller)).toBeNull();
});
});
Testing the update flow¶
A service worker update is a byte comparison. The browser fetches sw.js (and imported scripts), bypassing the HTTP cache according to updateViaCache, compares the bytes, and installs a new worker only if something changed. The new worker then waits until every client of the old worker is gone or the new worker calls skipWaiting() (Lifecycle, Updating). A test can drive the whole sequence deterministically:
- Serve a modified
sw.js(the fixture server'spatch()). - Call
registration.update()instead of waiting for the update check that the next in-scope navigation triggers. - Assert the app's update UI appears once the new worker is
installedand waiting. - Trigger
SKIP_WAITING, wait for the reload that followscontrollerchange, and assert the new version answers.
test.describe('updates', () => {
test('a changed sw.js installs, waits, and takes over on SKIP_WAITING', async ({ controlledPage: page, app }) => {
// Reload so the page *starts* controlled, like a returning visitor.
// The app only auto-reloads on controllerchange when it had a controller at load.
await page.reload();
// Ship a byte-different SW script with a new cache version.
app.patch('/sw.js', (source) => source.replace("const VERSION = 'v1'", "const VERSION = 'v2'"));
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
await reg.update();
});
// The app shows its update UI once the new worker reaches "installed".
const reloadButton = page.getByRole('button', { name: /update available/i });
await expect(reloadButton).toBeVisible();
expect(await page.evaluate(async () => !!(await navigator.serviceWorker.getRegistration()).waiting)).toBe(true);
// Clicking posts SKIP_WAITING; the app reloads on controllerchange.
await Promise.all([page.waitForEvent('load'), reloadButton.click()]);
await waitForServiceWorkerControl(page);
const version = await page.evaluate(() => new Promise((resolve) => {
navigator.serviceWorker.addEventListener('message', (e) => resolve(e.data.version), { once: true });
navigator.serviceWorker.controller.postMessage({ type: 'GET_VERSION' });
}));
expect(version).toBe('v2');
// Old caches are gone after activation.
expect(await page.evaluate(() => caches.keys())).not.toContain('precache-v1');
});
});
This test passes in Chromium, Firefox and WebKit. registration.update() rejects if the fetch fails, for example with a SecurityError about the script load in WebKit when the server is unreachable, so don't combine an update test with a network-down state unless the rejection is what you want to test. Updating Service Workers compares prompt, silent and reload-on-navigation strategies, and each needs its own variant of this test.
Testing cold starts and worker global state¶
Browsers stop idle workers (Chromium after about 30 seconds without events) and start them again for the next event, with fresh global state. A worker that keeps anything important in a global variable breaks only after it has been stopped, which rarely happens during a short test. CDP can stop workers on demand:
import { test, expect } from './support/fixtures.mjs';
test('the worker survives a stop/start cycle (Chromium)', async ({ context, controlledPage: page, browserName }) => {
test.skip(browserName !== 'chromium', 'CDP is Chromium-only');
const worker = context.serviceWorkers()[0];
await worker.evaluate(() => { self.__warm = true; });
const cdp = await context.newCDPSession(page);
await cdp.send('ServiceWorker.enable');
await cdp.send('ServiceWorker.stopAllWorkers');
// The next functional event starts the worker again from scratch.
const status = await page.evaluate(() => fetch('/app.js').then((r) => r.status));
expect(status).toBe(200);
// Same Worker handle, fresh global scope: in-memory state is gone.
expect(await worker.evaluate(() => self.__warm ?? 'fresh')).toBe('fresh');
});
In Playwright 1.63, the Worker handle stays in context.serviceWorkers() across the stop/start cycle and doesn't emit close. evaluate() on the same handle reaches the new global scope, which is what the last assertion proves. ServiceWorker.stopWorker (by versionId) and ServiceWorker.startWorker / skipWaiting / unregister / updateRegistration (by scopeURL) are also available for finer control. The next section lists them.
Testing push, background sync and periodic sync with CDP¶
Real push messages travel through a push service (FCM, Mozilla autopush, Apple's service) with encrypted payloads, which automated tests shouldn't depend on. The Web Push Protocol covers testing that path. What you can test automatically is everything from the event onward: your push handler's parsing, the notification it shows, and the side effects of sync handlers. Chromium's DevTools Protocol has an experimental ServiceWorker domain that does this, and it's the same mechanism behind the Push, Sync and Periodic sync buttons in DevTools' Application panel (Browser DevTools).
| CDP command | Parameters | Dispatches |
|---|---|---|
ServiceWorker.enable | none | Starts reporting workerRegistrationUpdated, workerVersionUpdated and workerErrorReported events, replaying current state |
ServiceWorker.deliverPushMessage | origin, registrationId, data (string) | A push event whose event.data.text() is data. No subscription, push service or encryption involved |
ServiceWorker.dispatchSyncEvent | origin, registrationId, tag, lastChance | A one-off sync event with that tag; lastChance sets event.lastChance |
ServiceWorker.dispatchPeriodicSyncEvent | origin, registrationId, tag | A periodicsync event with that tag |
ServiceWorker.stopWorker / stopAllWorkers | versionId / none | Terminates workers to test cold starts |
ServiceWorker.startWorker, skipWaiting, unregister, updateRegistration | scopeURL | Lifecycle control |
ServiceWorker.setForceUpdateOnPageLoad | forceUpdateOnPageLoad | The "Update on reload" checkbox |
registrationId is not exposed to page JavaScript. You get it from the ServiceWorker.workerRegistrationUpdated event, which enable replays immediately for existing registrations. ServiceWorkerVersion objects in workerVersionUpdated carry runningStatus (stopped, starting, running, stopping) and status (new, installing, installed, activating, activated, redundant). You can assert on these directly as well.
/**
* Chromium-only helpers built on the experimental CDP ServiceWorker domain.
* @param {import('@playwright/test').BrowserContext} context
* @param {import('@playwright/test').Page} page
*/
export async function openServiceWorkerSession(context, page) {
const cdp = await context.newCDPSession(page);
/** @type {Map<string, string>} scopeURL -> registrationId */
const registrations = new Map();
cdp.on('ServiceWorker.workerRegistrationUpdated', ({ registrations: regs }) => {
for (const r of regs) {
if (r.isDeleted) registrations.delete(r.scopeURL);
else registrations.set(r.scopeURL, r.registrationId);
}
});
// enable() replays the current registrations as events.
await cdp.send('ServiceWorker.enable');
return {
cdp,
/** Waits until the registration for `scopeURL` has been reported. */
async registrationId(scopeURL, { timeout = 5_000 } = {}) {
const deadline = Date.now() + timeout;
while (!registrations.has(scopeURL)) {
if (Date.now() > deadline) throw new Error(`No SW registration for ${scopeURL}`);
await new Promise((r) => setTimeout(r, 50));
}
return registrations.get(scopeURL);
},
/** Dispatches a push event with `data` as the payload (a string). */
async push(scopeURL, data) {
const origin = new URL(scopeURL).origin;
const registrationId = await this.registrationId(scopeURL);
await cdp.send('ServiceWorker.deliverPushMessage', { origin, registrationId, data });
},
/** Dispatches a one-off background sync event. */
async sync(scopeURL, tag, { lastChance = false } = {}) {
const origin = new URL(scopeURL).origin;
const registrationId = await this.registrationId(scopeURL);
await cdp.send('ServiceWorker.dispatchSyncEvent', { origin, registrationId, tag, lastChance });
},
/** Dispatches a periodic background sync event. */
async periodicSync(scopeURL, tag) {
const origin = new URL(scopeURL).origin;
const registrationId = await this.registrationId(scopeURL);
await cdp.send('ServiceWorker.dispatchPeriodicSyncEvent', { origin, registrationId, tag });
},
dispose: () => cdp.detach(),
};
}
import { test, expect } from './support/fixtures.mjs';
import { openServiceWorkerSession } from './support/cdp.mjs';
// Everything here drives the CDP ServiceWorker domain, which only Chromium has.
test.skip(({ browserName }) => browserName !== 'chromium', 'CDP is Chromium-only');
test.describe('push', () => {
// Requires channel: 'chromium' in the project config: the default
// headless shell reports Notification.permission as "denied" even when granted.
test.use({ permissions: ['notifications'] });
test('a push message shows a notification', async ({ context, controlledPage: page, app }) => {
const sw = await openServiceWorkerSession(context, page);
try {
await sw.push(`${app.url}/`, JSON.stringify({
title: 'Order shipped',
body: 'Your order #1042 is on its way',
tag: 'order-1042',
url: '/orders/1042',
}));
// showNotification() resolves before the notification is queryable; poll.
await expect.poll(() => page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return (await reg.getNotifications()).map((n) => ({ title: n.title, body: n.body, tag: n.tag, data: n.data }));
})).toEqual([{
title: 'Order shipped',
body: 'Your order #1042 is on its way',
tag: 'order-1042',
data: { url: '/orders/1042' },
}]);
} finally {
await sw.dispose();
}
});
});
test.describe('background sync', () => {
test('the outbox is flushed when a sync event fires', async ({ context, controlledPage: page, app }) => {
const sw = await openServiceWorkerSession(context, page);
try {
await sw.sync(`${app.url}/`, 'outbox', { lastChance: false });
await expect.poll(() => app.log.some((e) => e.method === 'POST' && e.path === '/api/outbox/flush')).toBe(true);
} finally {
await sw.dispose();
}
});
});
Notifications and the Chromium headless shell
Since Playwright 1.49, headless runs of the chromium browser use the separate chrome-headless-shell binary by default, and since 1.57 both that shell and the headed browser are Chrome for Testing builds (except on Arm64 Linux, which still uses Chromium). In that shell, permissions: ['notifications'] makes navigator.permissions.query({ name: 'notifications' }) report "granted", but Notification.permission is "denied" and showNotification() rejects with "No notification permission has been granted for this origin". Setting channel: 'chromium' runs the full browser in new headless mode, where the grant works. You can't set channel in test.use() inside a describe block, because it forces a new worker process. Set it on the project, or at the top level of a file.
The notification test polls getNotifications() because in new headless Chromium the list can still be empty for a short time after showNotification() resolves. Tests that read it immediately are flaky.
Some things the CDP route can't reach:
notificationclick. CDP has no command to click a notification, and dispatchingnew NotificationEvent('notificationclick', …)fromworker.evaluate()produces an untrusted event. ItswaitUntil()throwsInvalidStateError, andclients.openWindow()requires a real notification click. Move the click logic into a function (resolveClickTarget(notification.data)) and unit-test it instead (Notifications API).- The subscription and the server.
deliverPushMessageworks without a subscription, so a brokenpushManager.subscribe()flow passes. Test subscribing separately.subscribe()contacts the browser's push service, so a test that calls it depends on an external service and on outbound network access from CI. Mocking at the application boundary is more reliable (Push Notifications). - Firefox, Safari and iOS. Firefox's
about:debuggingand Safari's Web Inspector have manual push triggers, but no automation equivalent. iOS web push can only be tested on devices (Web Push on iOS & Safari).
The sync test asserts on the server's request log rather than on client-side state. That is the effect that matters, and it also proves that the worker's fetch() isn't blocked by anything in the test setup. Background Sync and Periodic Background Sync cover retry semantics (lastChance) worth testing too.
Puppeteer: service worker targets and offline mode¶
Puppeteer talks to Chrome over CDP (and to Firefox over WebDriver BiDi), and it exposes service workers as targets. target.type() returns 'service_worker' for them, alongside 'page', 'background_page', 'shared_worker', 'browser', 'webview' and 'other'. target.worker() returns a WebWorker handle with evaluate() and url(), or null for non-worker targets. target.createCDPSession() opens a CDP session attached directly to the worker. These features depend on CDP, so they are Chrome and Chromium only. Puppeteer 25 (May 2026) is ESM-only and requires Node 22.12 or later.
One behavior differs from Playwright, and it catches most people: page.setOfflineMode(true) emulates offline for the page's target only. The page's navigations are still answered by the worker, and navigator.onLine becomes false. But fetch() calls made inside the worker still reach the network. An offline test written this way passes whether or not your fallback works. The fix is to emulate offline on the worker target's own CDP session too:
// Smoke-tests a PWA's offline behavior with Puppeteer (Chrome/Chromium only).
// Usage: node scripts/puppeteer-offline-check.mjs http://localhost:4173/
import puppeteer from 'puppeteer';
const url = process.argv[2] ?? 'http://localhost:4173/';
const OFFLINE = { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 };
const ONLINE = { ...OFFLINE, offline: false };
const browser = await puppeteer.launch({
// Optional: point at a specific Chrome build in CI.
executablePath: process.env.CHROME_PATH || undefined,
});
try {
const page = await browser.newPage();
// 1. Start waiting for the worker target *before* navigating.
const swTargetPromise = browser.waitForTarget(
(t) => t.type() === 'service_worker' && t.url().startsWith(new URL(url).origin),
{ timeout: 15_000 },
);
await page.goto(url, { waitUntil: 'load' });
const swTarget = await swTargetPromise;
const worker = await swTarget.worker();
// 2. Wait until the SW is activated *and* controls this page.
await page.waitForFunction(async () => {
const reg = await navigator.serviceWorker.getRegistration();
return reg?.active?.state === 'activated' && navigator.serviceWorker.controller !== null;
}, { timeout: 15_000, polling: 100 });
const cacheNames = await worker.evaluate(() => caches.keys());
console.log(`Service worker ${worker.url()} controls the page; caches: ${cacheNames.join(', ')}`);
// 3. Go offline. page.setOfflineMode() only affects requests the *page*
// makes; the worker's own fetch() calls still reach the network. Emulate
// offline on the worker target's CDP session as well.
const swSession = await swTarget.createCDPSession();
await swSession.send('Network.enable');
await swSession.send('Network.emulateNetworkConditions', OFFLINE);
await page.setOfflineMode(true);
await page.reload({ waitUntil: 'load' });
const heading = await page.$eval('h1', (el) => el.textContent.trim());
const apiStatus = await page.evaluate(() => fetch('/api/items').then((r) => r.status));
console.log(`Offline reload rendered "${heading}"; /api/items -> ${apiStatus}`);
if (!heading) throw new Error('Offline reload rendered no heading');
if (apiStatus !== 503) throw new Error(`Expected the SW's offline API fallback, got ${apiStatus}`);
// 4. Restore the network for anything that runs afterwards.
await page.setOfflineMode(false);
await swSession.send('Network.emulateNetworkConditions', ONLINE);
} catch (error) {
console.error(error);
process.exitCode = 1;
} finally {
await browser.close();
}
Without step 3's worker-session emulation, the same script logged a 200 from the real server for /api/items while the page was "offline". CDP marks Network.emulateNetworkConditions as deprecated in favor of Network.emulateNetworkConditionsByRule plus Network.overrideNetworkState. The deprecated command still worked on the worker session in Chrome 153, and in the same test, emulateNetworkConditionsByRule with emulateOfflineServiceWorker: true on the page session did not stop the worker's fetch(). Pin your Chrome version and re-check after upgrades, because the whole domain is experimental.
The Push/Sync CDP commands from the previous section work unchanged in Puppeteer: const cdp = await page.createCDPSession(); await cdp.send('ServiceWorker.enable'); and so on. Unlike Playwright's isolated contexts, browser.newPage() opens a page in the default browser context, a normal (non-incognito) profile in a temporary user data directory. That's why Puppeteer scripts get meaningful results from Page.getInstallabilityErrors without extra setup. The script in Installability Criteria does exactly this.
Manifest and installability tests¶
Manifest regressions are silent. A renamed icon, a start_url that moved outside scope after a routing change, or a CDN that serves .webmanifest as application/octet-stream removes the install option without any console error. Two kinds of checks catch them:
- HTTP-level checks that run anywhere: the manifest is linked, parses as JSON, has the members browsers require, URLs resolve inside scope, and every icon exists with the dimensions it claims.
- Chromium's own verdict, from
Page.getInstallabilityErrors, the same check behind the Installability section of DevTools' Manifest pane.
import { chromium } from '@playwright/test';
import { test, expect } from './support/fixtures.mjs';
/** Reads width/height from a PNG's IHDR chunk (bytes 16-23). */
function pngSize(buffer) {
const signature = '89504e470d0a1a0a';
if (buffer.subarray(0, 8).toString('hex') !== signature) throw new Error('not a PNG');
return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
}
/** Loads "/" and returns the parsed manifest plus its absolute URL. */
async function loadManifest(page, request) {
await page.goto('/');
const href = await page.locator('link[rel="manifest"]').getAttribute('href');
expect(href, 'page must link a manifest').toBeTruthy();
const manifestUrl = new URL(href, page.url()).href;
const response = await request.get(manifestUrl);
expect(response.ok()).toBe(true);
// application/manifest+json is the registered type; JSON also works in browsers.
expect(response.headers()['content-type']).toMatch(/application\/(manifest\+)?json/);
return { manifest: await response.json(), manifestUrl };
}
test.describe('web app manifest', () => {
// Pure HTTP checks: run them once, not per browser.
test.skip(({ browserName }) => browserName !== 'chromium', 'engine-independent checks');
test('has the members Chromium requires for installation', async ({ page, request }) => {
const { manifest } = await loadManifest(page, request);
expect(manifest.name || manifest.short_name, 'name or short_name').toBeTruthy();
expect(manifest.start_url, 'start_url').toBeTruthy();
expect(['fullscreen', 'standalone', 'minimal-ui', 'window-controls-overlay'])
.toContain(manifest.display);
expect(manifest.prefer_related_applications ?? false).toBe(false);
});
test('start_url and id resolve inside scope', async ({ page, request }) => {
const { manifest, manifestUrl } = await loadManifest(page, request);
// Per the spec, start_url resolves against the manifest URL; scope defaults
// to the start_url's directory; id resolves against start_url's origin.
const startUrl = new URL(manifest.start_url, manifestUrl);
const scope = new URL(manifest.scope ?? '.', manifest.scope ? manifestUrl : startUrl);
expect(startUrl.origin).toBe(new URL(manifestUrl).origin);
expect(startUrl.href.startsWith(scope.href), `${startUrl} must be within ${scope}`).toBe(true);
if (manifest.id !== undefined) {
const id = new URL(manifest.id, startUrl.origin);
expect(id.origin).toBe(startUrl.origin);
}
});
test('icons exist, match their declared sizes, and include 192, 512 and maskable', async ({ page, request }) => {
const { manifest, manifestUrl } = await loadManifest(page, request);
const icons = manifest.icons ?? [];
const declared = new Set(icons.flatMap((i) => (i.sizes ?? '').split(/\s+/)));
expect(declared).toContain('192x192');
expect(declared).toContain('512x512');
expect(icons.some((i) => (i.purpose ?? 'any').split(/\s+/).includes('maskable'))).toBe(true);
for (const icon of icons) {
const url = new URL(icon.src, manifestUrl).href;
const res = await request.get(url);
expect(res.ok(), `${url} must load`).toBe(true);
if (icon.type === 'image/png') {
const { width, height } = pngSize(await res.body());
const sizes = (icon.sizes ?? '').split(/\s+/);
expect(sizes, `${url} is really ${width}x${height}`).toContain(`${width}x${height}`);
}
}
});
test('Chromium reports no installability errors', async ({ app }, testInfo) => {
// browser.newContext() is off-the-record, and Chromium refuses to install
// from incognito ("in-incognito"). A persistent context is a normal profile.
const userDataDir = testInfo.outputPath('profile');
const context = await chromium.launchPersistentContext(userDataDir, { channel: 'chromium' });
try {
const page = context.pages()[0] ?? await context.newPage();
await page.goto(`${app.url}/`);
const cdp = await context.newCDPSession(page);
const { errors, url } = await cdp.send('Page.getAppManifest');
expect(url).toMatch(/manifest\.webmanifest$/);
expect(errors, 'manifest parser warnings').toEqual([]);
const { installabilityErrors } = await cdp.send('Page.getInstallabilityErrors');
expect(installabilityErrors.map((e) => e.errorId)).toEqual([]);
} finally {
await context.close();
}
});
});
Notes on these checks:
in-incognitois a test artifact. The first version of the last test ran in the ordinarycontextfixture and failed withinstallabilityErrors: [{ errorId: 'in-incognito' }]. Playwright'sbrowser.newContext()creates off-the-record contexts, and Chromium's installability evaluator rejects those before looking at the manifest.launchPersistentContext()uses a real profile directory. Give each test its own directory (testInfo.outputPath()) so runs don't share state.Page.getAppManifestreturnsurl, the rawdata, parsererrors(each withmessage,critical,line,column), and an experimental parsedmanifestobject. Warnings such as an ignored invaliddisplayvalue appear here before they turn into installability errors, which is why the test fails on any parser warning.- Error IDs such as
no-manifest,manifest-missing-suitable-icon,manifest-display-not-supportedandno-acceptable-icon(with anerrorArgumentsentryminimum-icon-size-in-pixels) are documented, with fixes, in Installability Criteria. The criteria themselves are Chromium's. Safari and Firefox install from any page and read the manifest differently, so these checks are a floor, not a guarantee for every platform. - Member-level rules (valid
theme_color,shortcutsURLs within scope,screenshotsfor the richer install UI) belong in the same file. The Manifest Members Reference and Rich Install UI list the constraints worth asserting.
If you want a wider rule set without writing it, PWABuilder publishes its validator as @pwabuilder/manifest-validation, the same checks behind its report card (Lighthouse & Auditing). Caveats from running version 0.0.9, published in August 2023 and still the latest release in September 2026:
- Every function is
async, and the package is ESM-only ("type": "module"). isInstallReady()compares the length ofvalidateRequiredFields()'s result with zero, but that array includes passing checks ([{ member: 'name', valid: true }, …]), so it returnsfalsefor a manifest with every required field present.- Each result object has
category(required,recommendedoroptional),member,valid,displayString,errorString,infoString,docsLinkandquickFix.displayStringis the passing label ("Manifest has orientation field") even on a failing result, so printerrorStringfor failures. - The "Icons have at least one icon with purpose any" check fails unless an icon declares
"purpose": "any"explicitly, even thoughanyis the spec default whenpurposeis omitted. That's why the demo manifest above spells it out.
Filter the results yourself:
// Usage: node scripts/validate-manifest.mjs dist/manifest.webmanifest
import { readFile } from 'node:fs/promises';
import { validateManifest } from '@pwabuilder/manifest-validation';
const file = process.argv[2] ?? 'dist/manifest.webmanifest';
let manifest;
try {
manifest = JSON.parse(await readFile(file, 'utf8'));
} catch (error) {
// Broken JSON is the most common manifest regression; fail loudly.
console.error(`${file}: ${error.message}`);
process.exit(1);
}
const results = await validateManifest(manifest, true); // true: also report tests for missing members
const failures = results.filter((r) => r.valid === false);
for (const { category, member, errorString } of failures) {
console.log(`${category.padEnd(11)} ${member}: ${errorString}`);
}
// Fail the build only on "required" problems; "recommended"/"optional" are advisory.
process.exitCode = failures.some((r) => r.category === 'required') ? 1 : 0;
On the demo manifest, this prints about twenty recommended and optional lines (screenshots, display_override, launch_handler, edge_side_panel, share_target and others) and exits with 0. Remove name and it prints required name: name is required and must be a string with a length > 0 and exits with 1.
WebKit and Firefox: what you can and cannot automate¶
Playwright's WebKit is not Safari, and its Firefox is a patched build, not the branded browser. Both run your worker for real, which makes cross-engine E2E tests worthwhile. What they lack is the inspection layer. The consolidated picture from the tests on this page:
| Capability | Chromium | Firefox | WebKit | Workaround outside Chromium |
|---|---|---|---|---|
| Worker registers, installs, controls pages | ✅ | ✅ | ✅ | none needed |
Cache Storage from the page (caches.keys()) | ✅ | ✅ | ✅ | none needed |
response.fromServiceWorker() | ✅ | ✅ | ✅ | none needed |
Update flow (registration.update(), skipWaiting) | ✅ | ✅ | ✅ | none needed |
serviceWorkers: 'block' | ✅ | ✅ | ✅ | none needed |
Worker handles, worker.evaluate(), worker console | ✅ | ❌ | ❌ | postMessage a query to the worker and answer from a message handler |
| Routing requests made by the worker | ✅ | ❌ | ❌ | Control responses on the test server |
setOffline() cutting both page and worker network | ✅ | ❌ | ❌ | Drop connections on the test server |
| Push, sync, periodic sync events | ✅ (CDP) | ❌ | ❌ | Unit-test the handlers; manual checks in about:debugging or Web Inspector |
| Installability verdict | ✅ (CDP) | not applicable | not applicable | Firefox and Safari have no equivalent criteria to test |
Support data as of September 2026 (Playwright 1.63). Neither Playwright's WebKit on Linux nor on macOS behaves like Safari on iOS, where Home Screen web apps have separate storage from Safari and push only works after the app is added to the Home Screen. For iOS-specific behavior, test on devices or simulators with Safari's Web Inspector (iOS & iPadOS).
Running PWA tests in CI¶
A GitHub Actions workflow¶
name: PWA tests
on:
push:
branches: [main]
pull_request:
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: node --test 'test/**/*.test.js' # service worker unit and handler tests
e2e:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run build # tests run against the production build in dist/
# Full Chromium is needed for channel: 'chromium'; --with-deps installs OS libraries.
- run: npx playwright install --with-deps chromium firefox webkit
- run: npx playwright test
- uses: actions/upload-artifact@v5
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
Playwright's CI guide uses actions/checkout@v6, actions/setup-node@v6 and actions/upload-artifact@v5, and publishes Docker images such as mcr.microsoft.com/playwright:v1.63.0-noble if you prefer a container with browsers preinstalled. Match the image tag to the @playwright/test version in your lockfile. Add a third job for Lighthouse CI to catch performance and quality regressions. It needs the same build output.
Keeping PWA tests deterministic¶
- One origin per worker process. Service worker registrations, caches and permissions are per origin. The random-port server per worker in the fixtures means parallel tests never share a registration, even if a test leaks state.
localhostversus127.0.0.1. Both are secure contexts, so service workers work on plain HTTP. But they are different origins. A registration made on one is invisible on the other, and a test that mixes them waits forever for control.- Never cache
sw.js. A test server (or a preview server in CI) that sendsCache-Control: max-agefor the worker script can make update tests pass or fail depending on timing.updateViaCache: 'imports'is the default, so the main script bypasses the HTTP cache for update checks, but imported scripts and your CDN may not (HTTP Caching). - Poll, don't sleep.
expect.poll(),waitForFunction()andwaitForServiceWorkerControl()wait exactly as long as needed. AwaitForTimeout(1000)is either too short in CI or wasted time locally. - Retries hide lifecycle bugs. A test that only passes on retry often means a real race, such as asserting on the page before
clients.claim(). Check the trace for the first attempt before accepting a retry. - Workers are shared across tabs. Two pages in one context share a worker. An update test that opens a second tab must close it, or the new worker waits forever. That is the lifecycle working as specified.
Common pitfalls¶
Testing against the dev server
Framework dev servers often don't register a worker, register a dev-only worker, or serve modules unbundled from different URLs than production. An offline test that passes against vite dev proves little. Build first and serve dist/ (or your framework's preview server) in tests.
Asserting before the page is controlled
navigator.serviceWorker.ready resolves when the registration has an active worker, not when this page is controlled. Requests made in that gap go straight to the network, so a "served from cache" assertion fails in a flaky way. Wait for controller !== null, or reload.
Global serviceWorkers: 'block' hiding real bugs
Blocking workers suite-wide makes page.route() predictable but also means no test exercises the worker. Block only in the projects or files that need network mocks, and keep a dedicated service worker project with 'allow'.
Reload loops on controllerchange
Reloading on every controllerchange also reloads on the first install when the worker calls clients.claim(), and with DevTools' "Update on reload" it can loop. Record whether a controller existed at load and reload only then, as app.js above does.
Trusting page-level offline emulation in Puppeteer
page.setOfflineMode(true) leaves the worker's network intact. Tests of "network fails, serve cached API data" pass for the wrong reason. Emulate on the service_worker target too, or cut the network at the server.
Leaking state across tests with persistent contexts
A persistent context keeps registrations and caches between runs by design. Use a fresh userDataDir per test (testInfo.outputPath()), or clear state with Storage.clearDataForOrigin and ServiceWorker.unregister before each test.
Debugging failing PWA tests¶
- Traces. With
trace: 'retain-on-failure',npx playwright show-traceshows every request with a marker for service-worker-served responses. In Chromium, it also shows the worker's own requests as context-level network events. - Worker console. In Chromium, subscribe early:
context.on('serviceworker', (w) => w.on('console', (m) => console.log('[sw]', m.text()))). In other engines, forward logs from the worker to the page withpostMessagein a debug build. - CDP events.
ServiceWorker.workerErrorReporteddelivers uncaught errors in the worker (message, source URL, line, column), andworkerVersionUpdatedshows every state transition. Log both in a failing test to see where the lifecycle stopped. - Headed runs.
npx playwright test --headed --project=chromium --debuggives you DevTools, where the Application panel shows the worker, its state and Cache Storage (Browser DevTools).chrome://serviceworker-internalsworks in the headed browser too. - Reproduce outside the runner. If a test fails only in CI, run the Puppeteer script above against the CI build artifact. It isolates service worker behavior from fixture and runner logic.
Further reading¶
On this site
- Browser DevTools: the manual counterpart to everything on this page
- Lighthouse & Auditing: audits, user flows and Lighthouse CI after the PWA category's removal
- Service Worker Lifecycle and Updating Service Workers: the states and transitions these tests assert
- Handling Fetch Events and Caching Strategies: what the unit tests exercise
- Offline UX & Fallbacks: what a good offline experience looks like
- Push Notifications and Background Sync: the handlers the CDP tests drive
- Installability Criteria: error IDs and the Chromium install pipeline
- Production Checklist: where automated tests fit in a release process
External references
- Playwright: Service Workers and BrowserContext API
- Playwright: Network, Continuous Integration and Browsers
- Puppeteer: Target class and Page.setOfflineMode()
- Chrome DevTools Protocol: ServiceWorker domain
- Service Workers specification (W3C)
- Node.js test runner
- microsoft/playwright#42775: WebKit offline emulation and service workers