Testland
Browse all skills & agents

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-tests
View source

service-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:

  • 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:

FactWhere to find
Registration URL<script> tag or navigator.serviceWorker.register('/sw.js') in the page bundle
Whether skipWaiting() is called in installself.skipWaiting() inside an install listener
Whether Clients.claim() is called in activateself.clients.claim() inside an activate listener
# Inventory
grep -E "skipWaiting|clients\.claim" src/sw.ts > sw-lifecycle-inventory.txt

Per 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, 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. 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-patternWhy it failsFix
Assert state by polling reg.installing vs reg.waiting vs reg.activeRace: the field flips between samplesListen on statechange (Step 2)
Skip the waitUntil testSlow installs that block fetch/push are invisible until prodStep 3 with a fixture SW
Test only the skipWaiting() halfWithout 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 v2Bypasses the SW per mdn-sw (opens in new window); loses the lifecycle signalUse reg.update() (Steps 5, 6)
Assume updatefound fires every navigationPer mdn-sw (opens in new window), only when a new SW is foundStep 8 explicitly drives update()
Treat redundant as an errorIt's the terminal cleanup state per sw-spec (opens in new window) for superseded SWsStep 6 asserts it as success
Skip the per-version cache-cleanup testA v2 that activates but doesn't delete v1 caches doubles storageInclude in the worked upgrade path test
Pin the exact transition orderingThe spec allows intermediate states to be observed or not depending on timingAssert 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 (opens in new window); 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 (fetch side).
  • Browser variance. Firefox and WebKit implement the state machine but report statechange with slightly different intermediate samples per sw-spec (opens in new window); the assertions here use toContain to absorb the variance.

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-mock
import 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

  • Testing offline-first behavior (cache-first responses, queue requests during offline).
  • Validating cache invalidation on service worker version bump.
  • Testing message passing between page and service worker.
  • Verifying push notification subscription registration.

How to use

  1. Launch a persistent context so the worker registers (incognito-default contexts skip SW registration) and await waitForEvent('serviceworker') before asserting - see Setup.
  2. Assert one lifecycle or offline behavior end to end - see Worked example.
  3. Inspect worker state (SW_VERSION, cache keys) with serviceWorker.evaluate(), which survives MV3's ~30s suspend.
  4. Cover the caching contract: cache-first hits and network-first offline fallback (see Cache strategies).
  5. For version-bump cache invalidation, service-worker-mock unit tests, and push-subscription tests, see advanced-service-worker-tests.md (opens in new window).

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-patternWhy it failsFix
Test SW with chromium.launch() (incognito)SWs don't register in incognito-by-default contextsUse launchPersistentContext (Setup)
Skip waitForEvent('serviceworker')Race condition - serviceWorkers() returns empty before registrationAlways await the event (Setup)
Reuse user-data-dir across test runsStale SW from prior run answers requestsFresh userDataDir per test (Setup)
Test offline by killing dev serverSW caches still serve from network until setOffline(true)Use context.setOffline(true) (Worked example)
Forget to grant notifications permissionpushManager.subscribe rejects silentlycontext.grantPermissions(['notifications']) (see references)

Limitations

  • WebKit (Safari) and Firefox have different SW APIs; this harness targets Chromium-channel testing primarily.
  • Playwright service worker support is documented as experimental-stable; check current API status at the Playwright Chrome extensions docs (opens in new window) for breaking changes.
  • service-worker-mock does not implement all Workbox APIs - for Workbox-using SWs, integration tests via Playwright are required.

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 event

Worked 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:

  • 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

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.