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.
64
81%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
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.