service-worker-lifecycle-tests
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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill service-worker-lifecycle-testsservice-worker-lifecycle-tests
Overview
A service worker moves through six formal states per the W3C spec sw-spec (opens in new window): "parsed, installing, installed, activating, activated, redundant". Most "PWA broke after deploy" bugs are lifecycle bugs - a v2 SW stuck in installed (waiting) behind a v1 that won't release control; a skipWaiting() that activates v2 but leaves v1's caches alive; a Clients.claim() race against a hot- reload that flips the navigator.serviceWorker.controller mid-fetch.
This skill produces the per-SW lifecycle spec - a Playwright file with one test per transition cell plus a worked v1 → v2 upgrade-path test. The builder itself is laser-focused on the state machine; the general Playwright harness (context.serviceWorkers() + waitForEvent('serviceworker') patterns, service-worker-mock unit tests) and per-cache-strategy assertions are in references/playwright-sw-harness.md.
Composes with:
When to use
Workflow
Step 1 - Capture the SW under test
Read the SW file the team ships and record three facts:
| Fact | Where to find |
|---|---|
| Registration URL | <script> tag or navigator.serviceWorker.register('/sw.js') in the page bundle |
Whether skipWaiting() is called in install | self.skipWaiting() inside an install listener |
Whether Clients.claim() is called in activate | self.clients.claim() inside an activate listener |
# Inventory
grep -E "skipWaiting|clients\.claim" src/sw.ts > sw-lifecycle-inventory.txtPer mdn-sw (opens in new window), skipWaiting() activates sooner and Clients.claim() claims existing pages. The combination matters - skipWaiting without claim activates the new SW but leaves current tabs uncontrolled until reload.
Step 2 - Test: state machine entry - fresh install
On first access to a SW-controlled page the worker downloads and installs immediately per mdn-sw (opens in new window). The first-install test:
import { test, expect } from '@playwright/test';
test('first install transitions parsed → installing → installed → activating → activated', async ({ context, page }) => {
await page.goto('https://localhost:3000/');
// Capture statechange events as soon as the SW is reachable
const observed = await page.evaluate(() => new Promise<string[]>((resolve) => {
const states: string[] = [];
navigator.serviceWorker.register('/sw.js').then(reg => {
const w = reg.installing ?? reg.waiting ?? reg.active;
if (!w) { resolve(states); return; }
states.push(w.state);
w.addEventListener('statechange', () => {
states.push(w.state);
if (w.state === 'activated' || w.state === 'redundant') resolve(states);
});
});
// Hard timeout
setTimeout(() => resolve(states), 10_000);
}));
// Per sw-spec, the formal enum is parsed / installing / installed / activating / activated / redundant.
// Expect at minimum installed and activated in the trace.
expect(observed).toContain('installed');
expect(observed).toContain('activated');
});statechange fires on the corresponding ServiceWorker object whenever its state attribute changes per sw-spec (opens in new window).
Step 3 - Test: event.waitUntil extends the install phase
Per mdn-sw (opens in new window), waitUntil() on an install / activate event holds functional events (fetch, push) until its promise resolves - so a slow precache keeps the SW in installing.
test('SW install with slow precache stays in installing until waitUntil resolves', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
const phase = await page.evaluate(() => new Promise<string>((resolve) => {
navigator.serviceWorker.register('/sw-slow-install.js').then(reg => {
const w = reg.installing;
if (!w) { resolve('no installing'); return; }
// Sample state at ~500ms - the slow install should still be 'installing'
setTimeout(() => resolve(w.state), 500);
});
}));
expect(['installing', 'installed']).toContain(phase);
});This requires a slow-install SW fixture under tests/fixtures/sw-slow-install.js that calls event.waitUntil(new Promise(r => setTimeout(r, 2000))) inside its install handler.
Step 4 - Test: skipWaiting() collapses the waiting phase
Per mdn-skipwaiting (opens in new window), skipWaiting() "causes the waiting service worker to become the active service worker." Test the transition:
test('skipWaiting() makes v2 active without page reload', async ({ context, page }) => {
// Load v1
await page.goto('https://localhost:3000/?sw-version=1');
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
// Deploy v2 (the test server flips the SW response based on a query param header)
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
await reg!.update();
});
const waitingThenActive = await page.evaluate(() => new Promise<string>(async (resolve) => {
const reg = await navigator.serviceWorker.getRegistration();
// v2 should land in waiting…
if (reg!.waiting) {
// …then transition to activating when skipWaiting() fires
reg!.waiting.addEventListener('statechange', e => {
resolve((e.target as ServiceWorker).state);
});
} else {
resolve(reg!.active?.state ?? 'unknown');
}
}));
// After skipWaiting(), v2 reaches activated without manual reload
expect(['activating', 'activated']).toContain(waitingThenActive);
});If the SW under test does not call skipWaiting(), this test must assert v2 stays in installed/waiting until all v1-controlled tabs close - flip the expectation accordingly.
Step 5 - Test: Clients.claim() flips the controller
Per mdn-claim (opens in new window), Clients.claim() lets an active SW set itself as the controller for all in-scope clients.
test('clients.claim() makes v2 control the page mid-session', async ({ page, context }) => {
// v1 is active and controlling
await page.goto('https://localhost:3000/?sw-version=1');
const v1ScriptURL = await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL);
expect(v1ScriptURL).toMatch(/sw-v1/);
// Trigger v2 deploy + claim
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
await reg!.update();
});
// After claim() in v2's activate handler, controller flips
const v2ScriptURL = await page.waitForFunction(() => {
const c = navigator.serviceWorker.controller;
return c && c.scriptURL.includes('sw-v2') ? c.scriptURL : null;
});
expect(await v2ScriptURL.jsonValue()).toMatch(/sw-v2/);
});Per mdn-sw (opens in new window), skipWaiting() and claim() together force-activate the new SW; one without the other leaves a gap (see Step 1).
Step 6 - Test: old SW transitions to redundant
Per sw-spec (opens in new window), redundant is the terminal state - the old SW enters it when superseded. The transition is the cleanup signal the activate handler typically uses to drop old caches:
test('old SW transitions to redundant after v2 activates', async ({ context, page }) => {
await page.goto('https://localhost:3000/?sw-version=1');
const v1 = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
return reg!.active;
});
const finalState = await page.evaluate(() => new Promise<string>(async (resolve) => {
const reg = await navigator.serviceWorker.getRegistration();
const oldSW = reg!.active;
if (!oldSW) { resolve('no old'); return; }
oldSW.addEventListener('statechange', () => {
if (oldSW.state === 'redundant') resolve('redundant');
});
// Trigger v2 update path
await reg!.update();
setTimeout(() => resolve(oldSW.state), 8_000);
}));
expect(finalState).toBe('redundant');
});Step 7 - Test: navigator.serviceWorker.controller semantics
Per mdn-sw (opens in new window), navigator.serviceWorker.controller returns the SW controlling the current page, or null if no SW controls it (e.g. hard-reload, force-bypass, or fresh first load before activation). Test the boundary cases:
test('controller is null on first hard-reload, set after activation', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
// First load: controller may be null until claim() runs (or until next navigation)
const initialController = await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL ?? null);
// Either null (no claim) or set (claim called in activate)
// After a reload, the SW must be controlling
await page.reload();
const reloadedController = await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL);
expect(reloadedController).toBeTruthy();
});Per mdn-sw (opens in new window): a hard-reload (Ctrl+Shift+R) bypasses the SW - controller is null for that page even if an SW is registered. Playwright's page.reload({ waitUntil: 'networkidle' }) is a soft reload; the SW controls it.
Step 8 - Test: updatefound event on registration
Per mdn-sw (opens in new window), the registration object fires updatefound when a new SW is in the installing state. This is the canonical "deploy detected" event for "Update available" banners:
test('updatefound fires when a new SW is found', async ({ page, context }) => {
await page.goto('https://localhost:3000/?sw-version=1');
const found = await page.evaluate(() => new Promise<boolean>(async (resolve) => {
const reg = await navigator.serviceWorker.getRegistration();
reg!.addEventListener('updatefound', () => resolve(true));
await reg!.update();
setTimeout(() => resolve(false), 5_000);
}));
expect(found).toBe(true);
});Step 9 - Emit the lifecycle spec artifact
Write tests/sw-lifecycle.spec.ts with all eight test cells above, paired with a tests/sw-lifecycle-coverage.yaml matrix mapping each spec to its state-machine cell and reference. The full matrix and the worked v1 -> v2 upgrade-path spec are in references/upgrade-path.md:
# tests/sw-lifecycle-coverage.yaml
matrix:
fresh_install:
spec: "first install transitions parsed → installing → installed → activating → activated"
states: [parsed, installing, installed, activating, activated]
ref: sw-spec ServiceWorkerState enum
# waituntil, skipwaiting, claim, redundant, controller_semantics, updatefoundCI gates on every matrix row having at least one passing test.
Worked example: a v1 → v2 upgrade-path test
The full worked tests/sw-upgrade-path.spec.ts for an SW using skipWaiting()
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Assert state by polling reg.installing vs reg.waiting vs reg.active | Race: the field flips between samples | Listen on statechange (Step 2) |
Skip the waitUntil test | Slow installs that block fetch/push are invisible until prod | Step 3 with a fixture SW |
Test only the skipWaiting() half | Without claim(), current tabs stay on v1 forever per mdn-claim (opens in new window) | Step 5 covers the second half |
| Hard-reload between v1 and v2 | Bypasses the SW per mdn-sw (opens in new window); loses the lifecycle signal | Use reg.update() (Steps 5, 6) |
Assume updatefound fires every navigation | Per mdn-sw (opens in new window), only when a new SW is found | Step 8 explicitly drives update() |
Treat redundant as an error | It's the terminal cleanup state per sw-spec (opens in new window) for superseded SWs | Step 6 asserts it as success |
| Skip the per-version cache-cleanup test | A v2 that activates but doesn't delete v1 caches doubles storage | Include in the worked upgrade path test |
| Pin the exact transition ordering | The spec allows intermediate states to be observed or not depending on timing | Assert presence with toContain, not exact array equality (Step 2) |
Limitations
References
Advanced service worker tests
View source (opens in new window)Advanced service worker tests
Deep reference for service-worker-lifecycle-tests. Consult for the deeper recipes past the persistent-context setup and offline worked example in playwright-sw-harness.md (opens in new window): version-bump cache invalidation, service-worker-mock unit tests, and push-notification subscription tests.
Version bump + cache invalidation
Assert a v2 worker deletes the v1 caches when it activates:
test('SW v2 deletes v1 caches on activate', async ({ context, page }) => {
await page.goto('https://localhost:3000');
let sw = context.serviceWorkers()[0]
?? await context.waitForEvent('serviceworker');
const v1Caches = await sw.evaluate(() => caches.keys());
expect(v1Caches).toContain('app-v1');
// Trigger SW update (deploy v2 to test server)
await page.evaluate(() => navigator.serviceWorker.getRegistration().then(r => r?.update()));
// Wait for activation
await page.waitForFunction(() =>
navigator.serviceWorker.controller?.scriptURL.includes('v2')
);
const v2Caches = await sw.evaluate(() => caches.keys());
expect(v2Caches).toContain('app-v2');
expect(v2Caches).not.toContain('app-v1');
});Unit test the SW with service-worker-mock
For Jest/Vitest unit tests that don't need a browser:
npm install --save-dev service-worker-mockimport makeServiceWorkerEnv from 'service-worker-mock';
beforeEach(() => {
Object.assign(global, makeServiceWorkerEnv());
jest.resetModules();
});
test('install event opens cache and pre-caches assets', async () => {
await import('../src/sw.js');
await self.trigger('install');
expect(self.snapshot().caches['app-v1']).toBeDefined();
expect(self.snapshot().caches['app-v1']['/index.html']).toBeDefined();
});Push notification subscription test
test('push subscription created on registration', async ({ page, context }) => {
await context.grantPermissions(['notifications']);
await page.goto('https://localhost:3000');
const subscription = await page.evaluate(async () => {
const reg = await navigator.serviceWorker.ready;
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: '<base64-vapid-key>',
});
});
expect(subscription).toBeDefined();
});Pair with web-push-tests for downstream send/receive assertions.
References
General Playwright service-worker harness
View source (opens in new window)General Playwright service-worker harness
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 (opens in new window), Playwright accesses service workers via context.serviceWorkers() and persists Worker objects across MV3's ~30s auto-suspend.
When to use
How to use
Setup - Playwright persistent context
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 (opens in new window), the same context.serviceWorkers() pattern applies to PWA service workers (not just extensions).
Worked example - offline fallback end to end
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();
});Evaluate in the worker context
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 (opens in new window), Playwright keeps the same Worker object alive across MV3 auto-suspend (~30s) - evaluate() calls continue transparently after restart.
Cache strategies
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-patterns
| 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) |
Limitations
References
Lifecycle coverage matrix and v1 -> v2 upgrade-path spec
View source (opens in new window)Lifecycle coverage matrix and v1 -> v2 upgrade-path spec
Companion detail for service-worker-lifecycle-tests. The per-transition test cells (Steps 2 to 8) are the runnable core and stay in SKILL.md; this file holds the coverage matrix and the worked upgrade-path spec.
Coverage matrix
tests/sw-lifecycle-coverage.yaml maps each spec to its state-machine cell. CI gates on every matrix row having at least one passing test.
# tests/sw-lifecycle-coverage.yaml
matrix:
fresh_install:
spec: "first install transitions parsed → installing → installed → activating → activated"
states: [parsed, installing, installed, activating, activated]
ref: sw-spec ServiceWorkerState enum
waituntil:
spec: "SW install with slow precache stays in installing until waitUntil resolves"
states: [installing]
ref: mdn-sw waitUntil semantics
skipwaiting:
spec: "skipWaiting() makes v2 active without page reload"
states: [installed, activating, activated]
ref: mdn-skipwaiting
claim:
spec: "clients.claim() makes v2 control the page mid-session"
states: [activated]
ref: mdn-claim
redundant:
spec: "old SW transitions to redundant after v2 activates"
states: [redundant]
ref: sw-spec ServiceWorkerState enum
controller_semantics:
spec: "controller is null on first hard-reload, set after activation"
ref: mdn-sw controller property
updatefound:
spec: "updatefound fires when a new SW is found"
ref: mdn-sw updatefound eventWorked example: a v1 -> v2 upgrade-path test
For an SW that uses skipWaiting() + Clients.claim():
// tests/sw-upgrade-path.spec.ts
import { test, expect } from '@playwright/test';
test('v1 → v2 upgrade: skip waiting + claim, old cache deleted', async ({ page, context }) => {
// 1. Land on v1 and confirm it controls the page.
await page.goto('https://localhost:3000/?sw-version=1');
await page.waitForFunction(() => navigator.serviceWorker.controller !== null);
const v1Cache = await page.evaluate(async () => {
const names = await caches.keys();
return names.find(n => n.endsWith('-v1'));
});
expect(v1Cache).toBeTruthy();
// 2. Trigger v2 deploy.
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
await reg!.update();
});
// 3. Wait for the controller to flip to v2 (skipWaiting + claim).
const v2Controller = await page.waitForFunction(() => {
const c = navigator.serviceWorker.controller;
return c && c.scriptURL.endsWith('sw-v2.js') ? c.scriptURL : null;
}, { timeout: 10_000 });
expect(await v2Controller.jsonValue()).toMatch(/sw-v2/);
// 4. Confirm v1 caches are deleted by v2's activate handler.
const remainingCaches = await page.evaluate(() => caches.keys());
expect(remainingCaches.some((n: string) => n.endsWith('-v1'))).toBe(false);
expect(remainingCaches.some((n: string) => n.endsWith('-v2'))).toBe(true);
});This single test exercises four state transitions (installed → activating in v2, activated → redundant in v1) plus the cache-cleanup convention.
Source references:
Related skills
add-to-homescreen-flow-tests
The single install-flow skill: the reference contract for the PWA install flow (installability gate fields, `beforeinstallprompt` handshake, per-platform paths - Android WebAPK / iOS Share menu / Firefox no-op - and the `display-mode` post-install signal, in references/install-flow-reference.md) plus the build-an-X workflow that emits the Add-to-Home-Screen suite. Walks the four-stage timeline, emitting one test per gate cell per [web.dev/articles/install-criteria][install-criteria], the deferred-prompt → `prompt()` → `userChoice` chain per [web.dev/articles/customize-install][customize-install], the iOS Safari manual-metadata branch (`apple-touch-icon`) per [web.dev/learn/pwa/installation][learn-pwa], and the post-install `(display-mode: standalone)` MQ assertion. Output: a Playwright spec with per-stage cells plus a coverage matrix. Use when a PWA's manifest, icons, or install handler change, when install conversion drops at an unknown stage, or when triaging a flaky install assertion.
offline-fallback-tests
Build-an-X workflow that emits the offline-fallback test suite. Walks the eight Jake Archibald offline recipes per [web.dev/articles/offline-cookbook][off-cookbook] (`Cache only`, `Network only`, `Cache, falling back to network`, `Cache and network race`, `Network falling back to cache`, `Cache then network`, `Generic fallback`, `Service Worker side templating`), maps each recipe to its assertion shape, layers the Workbox `offlineFallback()` recipe per [developer.chrome.com/docs/workbox/modules/workbox-recipes][wb-recipes], and pins the offline storage strategy (Cache Storage vs IndexedDB vs Storage Manager `persist()`/`estimate()`) per [web.dev/learn/pwa/offline-data][off-data]. Output: a Playwright spec file with one test per route's chosen recipe + a coverage matrix mapping recipes to URL patterns. Use when a route's SW caching behavior is being chosen or changed, or when a "doesn't work offline" report needs the failing route's recipe localized.
web-push-tests
Test the browser web-push subscription lifecycle - `pushManager.subscribe({ userVisibleOnly, applicationServerKey })` per [W3C Push API][w3c-push] returning a `PushSubscription` with `endpoint` + `keys.p256dh` + `keys.auth` + optional `expirationTime`; the `pushsubscriptionchange` service-worker event on refresh / revoke / expiry; the `push` event delivery with `PushMessageData`; VAPID auth per RFC 8292 (ES256 JWT, `aud` / `exp` ≤ 24h / `sub`); RFC 8030 push-service responses (201 Created, 410 Gone for expired endpoints, 413 Payload Too Large, 429); and `unsubscribe()` cleanup. Use when a PWA ships web-push and the subscription lifecycle needs release-gate coverage - scoped to the browser Push API, not to cross-channel delivery over native APNs / FCM.
workbox-tests
Test Workbox-built service workers - pin behavior of the named recipes (`pageCache`, `staticResourceCache`, `imageCache`, `googleFontsCache`, `offlineFallback`, `warmStrategyCache`) per [developer.chrome.com/docs/workbox/modules/workbox-recipes][wb-recipes]; validate `workbox-precaching` manifest injection (`__WB_MANIFEST` revisioning); assert `workbox-routing` route handler matches; assert `workbox-expiration` and `workbox-cacheable-response` plugin gates; and verify the `workbox-window` registration helper events (`installed`, `waiting`, `controlling`, `activated`). Cache-strategy design (which strategy per route type, TTL + version-bump invalidation, migrating a hand-rolled sw.js to Workbox) lives in references/cache-strategy-design.md. Use when a project ships (or is adopting) a Workbox service worker and its strategy choices or recipe behavior need pinning against refactors or a version upgrade.