Build-an-X workflow that emits per-SW state-transition tests covering the six `ServiceWorkerState` values per [w3c-github-io/ServiceWorker][sw-spec] (`parsed → installing → installed → activating → activated → redundant`), the `install` / `activate` / `fetch` event handlers per [MDN Service Worker API][mdn-sw], `event.waitUntil()` lifetime extension, `ServiceWorkerGlobalScope.skipWaiting()` and `Clients.claim()` upgrade-path semantics, the `statechange` event on `ServiceWorker` objects, `ServiceWorkerRegistration.update()`, and `navigator.serviceWorker.controller` checks. Output: a Playwright spec file with one test per transition plus a clean upgrade-path test (v1 active → v2 installed/waiting → v2 activated, with claim()). The general Playwright SW harness, `service-worker-mock` unit tests, and cache-strategy assertions live in references/. Use when authoring the baseline lifecycle spec, when a deploy leaves users stuck on the old service worker, or when a site's caching / offline behavior is uncovered.
65
82%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Reference for service-worker-lifecycle-tests: the general Playwright
SW test harness (context.serviceWorkers() +
waitForEvent('serviceworker')), service-worker-mock unit tests, and
per-cache-strategy assertions (cache-first, network-first). Per the
Playwright Chrome extensions docs, Playwright accesses service
workers via context.serviceWorkers() and persists Worker objects
across MV3's ~30s auto-suspend.
await waitForEvent('serviceworker') before asserting - see Setup.SW_VERSION, cache keys) with serviceWorker.evaluate(), which survives MV3's ~30s suspend.service-worker-mock unit tests, and push-subscription tests, see advanced-service-worker-tests.md.import { test, expect, chromium } from '@playwright/test';
test('service worker registers on first load', async () => {
const userDataDir = '/tmp/test-user-data';
const context = await chromium.launchPersistentContext(userDataDir, {
headless: false, // needed for SW registration in some Chromium versions
});
const page = await context.newPage();
await page.goto('https://localhost:3000');
// Wait for the SW to register
let [serviceWorker] = context.serviceWorkers();
if (!serviceWorker) {
serviceWorker = await context.waitForEvent('serviceworker');
}
expect(serviceWorker.url()).toContain('/sw.js');
await context.close();
});Per Playwright Chrome extensions docs, the same context.serviceWorkers()
pattern applies to PWA service workers (not just extensions).
One test that registers the worker, drops the network, and asserts the network-first fallback renders - the smallest complete offline check:
test('registered SW serves the offline page when network drops', async () => {
const context = await chromium.launchPersistentContext('/tmp/sw-offline', {
headless: false,
});
const page = await context.newPage();
// 1. Load online so the SW installs and pre-caches
await page.goto('https://localhost:3000');
let [sw] = context.serviceWorkers();
sw ??= await context.waitForEvent('serviceworker');
expect(sw.url()).toContain('/sw.js');
await page.waitForLoadState('networkidle');
// 2. Drop the network and reload
await context.setOffline(true);
await page.reload();
// 3. The SW's offline fallback answers instead of a network error
await expect(page.locator('text=You are offline')).toBeVisible();
await context.close();
});const swVersion = await serviceWorker.evaluate(() => {
return self.SW_VERSION;
});
expect(swVersion).toBe('1.4.2');
// Inspect cache contents
const cachedUrls = await serviceWorker.evaluate(async () => {
const cache = await caches.open('app-v1');
const reqs = await cache.keys();
return reqs.map(r => r.url);
});
expect(cachedUrls).toContain('https://localhost:3000/manifest.json');evaluate() proxies through the worker's JS context. Per
Playwright Chrome extensions docs, Playwright keeps the same Worker
object alive across MV3 auto-suspend (~30s) - evaluate() calls
continue transparently after restart.
Cache-first:
test('cache-first returns from cache, no network', async ({ page }) => {
await page.goto('https://localhost:3000');
await page.waitForLoadState('networkidle');
// Block network to force cache hits
await page.route('**/static/**', route => route.abort('failed'));
await page.reload();
// Page still renders from SW cache
await expect(page.locator('h1')).toBeVisible();
});Network-first with offline fallback:
test('network-first falls back to offline page', async ({ page, context }) => {
await page.goto('https://localhost:3000');
await page.waitForLoadState('networkidle');
await context.setOffline(true);
await page.reload();
await expect(page.locator('text=You are offline')).toBeVisible();
});| Anti-pattern | Why it fails | Fix |
|---|---|---|
Test SW with chromium.launch() (incognito) | SWs don't register in incognito-by-default contexts | Use launchPersistentContext (Setup) |
Skip waitForEvent('serviceworker') | Race condition - serviceWorkers() returns empty before registration | Always await the event (Setup) |
| Reuse user-data-dir across test runs | Stale SW from prior run answers requests | Fresh userDataDir per test (Setup) |
| Test offline by killing dev server | SW caches still serve from network until setOffline(true) | Use context.setOffline(true) (Worked example) |
| Forget to grant notifications permission | pushManager.subscribe rejects silently | context.grantPermissions(['notifications']) (see references) |
service-worker-mock does not implement all Workbox APIs - for
Workbox-using SWs, integration tests via Playwright are required.launchPersistentContext,
context.serviceWorkers(), MV3 lifecycle behavior.service-worker-mock unit tests,
push-subscription tests.