Service Worker Lifecycle Tests
ASecurityBuild-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, `Serv...
- 10 stars
- 0 votes
- 0 copies
- 1 view
- Added October 12, 2026
Works with
Security analysis
100/100Pro scans all 5 files and shows the line behind each finding
npx -y skills add testland/qa --skill service-worker-lifecycle-tests --agent claude-codeAre you the author of Service Worker Lifecycle Tests?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/testland-service-worker-lifecycle-tests)---
name: service-worker-lifecycle-tests
description: "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."
metadata:
keywords: "service-worker, lifecycle, skip-waiting, clients-claim, statechange"
version: "1.2.7"
---
# service-worker-lifecycle-tests
## Overview
A service worker moves through six formal states per the W3C
spec [sw-spec]: *"`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](references/playwright-sw-harness.md).
[sw-spec]: https://w3c.github.io/ServiceWorker/
[mdn-sw]: https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
[mdn-skipwaiting]: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/skipWaiting
[mdn-claim]: https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim
Composes with:
- `add-to-homescreen-flow-tests` (references/install-flow-reference.md) - the install-gate Stage 1 service-worker-registered prerequisite, which
*this* builder takes as input (assumes registration already
works).
- `workbox-tests` - the
`workbox-window` event vocabulary (`installed`, `waiting`,
`controlling`, `activated`, `redundant`) is the page-side
observable for the same state machine asserted here from the SW
side.
## When to use
- New PWA - author the baseline lifecycle spec before the team
ships any SW logic that mutates state.
- Upgrade-path regression - a deploy left users on v1 because v2's
`skipWaiting()` was missing; emit the per-transition test cells
to catch it next time.
- "Stale UI after deploy" reports - the test cells localize
whether the bug is `skipWaiting`, `Clients.claim`, or cache
invalidation.
- Migrating from Workbox `workbox-window` to a hand-rolled
registration helper - assert the same five events still fire.
## 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 |
```bash
# Inventory
grep -E "skipWaiting|clients\.claim" src/sw.ts > sw-lifecycle-inventory.txt
```
Per [mdn-sw], `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]. The first-install test:
```ts
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].
### Step 3 - Test: `event.waitUntil` extends the install phase
Per [mdn-sw], `waitUntil()` on an `install` / `activate` event holds functional
events (`fetch`, `push`) until its promise resolves - so a slow precache keeps
the SW in `installing`.
```ts
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], `skipWaiting()` "causes the waiting service
worker to become the active service worker." Test the transition:
```ts
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], `Clients.claim()` lets an active SW set itself as the
controller for all in-scope clients.
```ts
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], `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], `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:
```ts
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], `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:
```ts
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]: 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], 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:
```ts
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](references/upgrade-path.md):
```yaml
# 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, updatefound
```
CI 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()`
+ `Clients.claim()` is in
[references/upgrade-path.md](references/upgrade-path.md). It exercises four
state transitions (installed → activating in v2, activated → redundant in v1)
plus the cache-cleanup convention. Pair it with the per-transition cells from
Steps 2 - 8 for the full lifecycle surface.
## 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] | Step 5 covers the second half |
| Hard-reload between v1 and v2 | Bypasses the SW per [mdn-sw]; loses the lifecycle signal | Use `reg.update()` (Steps 5, 6) |
| Assume `updatefound` fires every navigation | Per [mdn-sw], 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] 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
- **`waitUntil` test timing is heuristic.** Step 3 samples at 500ms;
faster machines may see `installed` already. Use `waitForFunction`
with a state predicate for production-grade tests.
- **`controller` on first load** can be `null` or set depending on
whether the SW calls `claim()` per [mdn-sw]; Step 7 covers both
branches. Tests that hard-pin to one will flake.
- **Cross-tab lifecycle** isn't observed by this builder. Two open
tabs share the SW registration but each has its own
`serviceWorker.controller`; a full multi-tab assertion needs a
second `context.newPage()`.
- **Hard-reload (`Ctrl+Shift+R`) behavior** can't be triggered
programmatically in Playwright - `page.reload()` is always soft.
Manual smoke covers this cell.
- **The push and fetch events** that `waitUntil` gates aren't
tested here directly; pair with
`web-push-tests` (push side) and the cache-strategy tests in
[references/playwright-sw-harness.md](references/playwright-sw-harness.md)
(fetch side).
- **Browser variance.** Firefox and WebKit implement the state
machine but report `statechange` with slightly different
intermediate samples per [sw-spec]; the assertions here use
`toContain` to absorb the variance.
## References
- W3C Service Worker spec (`ServiceWorkerState` enum, formal state
values, `statechange` event semantics) - [sw-spec].
- MDN Service Worker API (lifecycle prose, `waitUntil`,
`skipWaiting` + `claim` pairing, `controller` semantics) -
[mdn-sw].
- MDN `ServiceWorkerGlobalScope.skipWaiting()` - [mdn-skipwaiting].
- MDN `Clients.claim()` - [mdn-claim].
- General Playwright SW harness, `service-worker-mock` unit tests,
cache-strategy assertions -
[references/playwright-sw-harness.md](references/playwright-sw-harness.md)
and
[references/advanced-service-worker-tests.md](references/advanced-service-worker-tests.md).
- Composes:
`add-to-homescreen-flow-tests`,
`workbox-tests`.
- Sibling builders:
`offline-fallback-tests`,
`add-to-homescreen-flow-tests`.
Files in this skill
- .tessl-plugin/plugin.json
- SKILL.md
- references/advanced-service-worker-tests.md
- references/playwright-sw-harness.md
- references/upgrade-path.md
Attribution
Comments
Loading comments…
Newsletter
Get the weekly list of safe new skills, and the ones we pulled.