Testland
Browse all skills & agents

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.

Install with skills.sh (any agent)

npx skills add testland/qa --skill workbox-tests
View source

workbox-tests

Overview

This skill tests Workbox-built service workers: assert that an already-shipped Workbox SW behaves the way its recipes claim, using the workbox-precaching / workbox-routing / workbox-strategies / workbox-recipes / workbox-window packages per developer.chrome.com/docs/workbox/modules (opens in new window). The design-side counterpart - choosing a strategy per route type and authoring the Workbox strategy code before pinning it - is in references/cache-strategy-design.md.

Pinned version

Time-sensitive pin - re-check at wb-gh (opens in new window) on upgrade: Workbox v7.4.1 (released May 2026).

When to use

  • A PWA already uses Workbox and tests need to lock its behavior against future refactors.
  • Migrating from Workbox v6 → v7 - assert each recipe behaves the same on the new release.
  • A "stale forever" bug report - pin the workbox-expiration plugin's TTL with a test before patching.
  • A workbox-precaching injection drifted (build emits wrong __WB_MANIFEST) - assert the precache manifest shape in CI.

Choosing a cache strategy

When the SW under test doesn't exist yet, or a hand-rolled sw.js is being migrated to Workbox, start with references/cache-strategy-design.md: the strategy-per-route-type table (CacheFirst / NetworkFirst / StaleWhileRevalidate / NetworkOnly), worked Workbox strategy code with ExpirationPlugin + CacheableResponsePlugin, the matching Playwright assertions, an audit table classifying existing hand-rolled fetch handlers, and invalidation locking (SW_VERSION bump + user-opt-in skipWaiting).

Authoring

Step 1 - Install test dependencies

Workbox ships no first-party test runner; the canonical pairing is Playwright (for runtime SW assertions) plus a unit-test runner (Vitest or Jest) for the workbox-window page-side helper:

npm install --save-dev @playwright/test vitest
# Workbox itself is already a runtime dep at this point

Step 2 - Decide where each assertion lives

SubjectRunnerWhy
Precache manifest shape (__WB_MANIFEST)Vitest reading the built sw.js artifactStatic; no browser needed
Recipe behavior at runtime (pageCache, imageCache)PlaywrightNeeds a real caches API + fetch interception
workbox-window events on the pagePlaywright (page side)Listens on wb.addEventListener(...) from page code
Plugin TTL / quota (workbox-expiration)Playwright with clock manipulationNeeds the SW to actually call the plugin's pruning logic

Step 3 - Author the precache-manifest static assertion

// tests/precache-manifest.spec.ts
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';

describe('workbox-precaching manifest', () => {
  it('emits the __WB_MANIFEST entries with revision strings', () => {
    const sw = readFileSync('./dist/sw.js', 'utf8');
    // workbox-precaching tokens; per wb-modules
    expect(sw).toMatch(/precacheAndRoute\s*\(/);
    // Build-tool injects __WB_MANIFEST as an array of { url, revision } records
    const manifest = sw.match(/self\.__WB_MANIFEST\s*=\s*(\[[^;]+\])/)?.[1];
    expect(manifest).toBeDefined();
    const entries = JSON.parse(manifest!);
    expect(Array.isArray(entries)).toBe(true);
    for (const entry of entries) {
      expect(typeof entry.url).toBe('string');
      // Hashed filenames carry revision: null; non-hashed must have a revision string
      const isHashed = /\.[a-f0-9]{8,}\./.test(entry.url);
      if (!isHashed) expect(typeof entry.revision).toBe('string');
    }
  });
});

precacheAndRoute() is the entry point exported from workbox-precaching per wb-modules (opens in new window) - it precaches a file set and manages updates to those files.

Step 4 - Author per-recipe runtime tests

Each named recipe has a documented default per wb-recipes (opens in new window); pin those defaults with tests. imageCache() is a cache-first strategy with defaults of 60 images cached for 30 days - pin the 60-entry cap:

import { test, expect } from '@playwright/test';

test('imageCache() applies the 60-entry default cap', async ({ context, page }) => {
  await page.goto('https://localhost:3000/gallery');
  await page.waitForLoadState('networkidle');

  // Force 61 distinct image requests
  for (let i = 0; i < 61; i++) {
    await page.evaluate((n) => fetch(`/img/test-${n}.png`).catch(() => {}), i);
  }

  // Wait for ExpirationPlugin to prune (it runs async)
  await page.waitForTimeout(500);

  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');

  const count = await sw.evaluate(async () => {
    const cacheName = (await caches.keys()).find(k => k.includes('image'));
    if (!cacheName) return 0;
    return (await (await caches.open(cacheName)).keys()).length;
  });
  expect(count).toBeLessThanOrEqual(60);
});

The other five recipe tests - pageCache() (network-first, 3s network-timeout default), offlineFallback() (offline.html default), googleFontsCache() (30 fonts / 1 year), staticResourceCache() (stale-while-revalidate), and warmStrategyCache() (warms declared URLs on install) - are in references/recipe-tests.md, each retaining its test-invariant default.

Step 5 - Author workbox-window event tests

workbox-window is the page-side companion for registering the SW, managing updates, and responding to lifecycle events per wb-modules (opens in new window). It emits installed, waiting, controlling, activated, and redundant. Listen on each from the page context:

test('wb.addEventListener installed fires after register()', async ({ page }) => {
  await page.goto('https://localhost:3000/');

  const events = await page.evaluate(() => new Promise<string[]>((resolve) => {
    // @ts-expect-error workbox-window global from the page bundle
    const wb = new window.Workbox('/sw.js');
    const fired: string[] = [];
    wb.addEventListener('installed',   () => fired.push('installed'));
    wb.addEventListener('waiting',     () => fired.push('waiting'));
    wb.addEventListener('controlling', () => fired.push('controlling'));
    wb.addEventListener('activated',   () => fired.push('activated'));
    wb.register();
    setTimeout(() => resolve(fired), 3000);
  }));

  // First install fires installed + activated; waiting only fires on update with a controller already present
  expect(events).toContain('installed');
  expect(events).toContain('activated');
});

The five-event vocabulary is enumerated in wb-modules (opens in new window) under workbox-window.

Step 6 - Test the cacheable-response plugin gate

Per wb-modules (opens in new window), workbox-cacheable-response restricts which requests are cached by response status code or headers. A common config is statuses: [200]. Assert that a 404 is not cached:

test('CacheableResponsePlugin excludes non-200 from cache', async ({ context, page }) => {
  await page.goto('https://localhost:3000/');
  await page.evaluate(() => fetch('/api/known-404').catch(() => {}));
  await page.waitForTimeout(300);

  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');

  const cachedKnown404 = await sw.evaluate(async () => {
    for (const name of await caches.keys()) {
      const c = await caches.open(name);
      for (const req of await c.keys()) {
        if (req.url.endsWith('/api/known-404')) return true;
      }
    }
    return false;
  });
  expect(cachedKnown404).toBe(false);
});

Running

Locally

npm run build           # produces dist/sw.js with __WB_MANIFEST injected
npx vitest run tests/precache-manifest.spec.ts
npx playwright test tests/workbox-recipes.spec.ts

The build step is non-optional - workbox-precaching only emits the precache manifest at build time per wb-modules (opens in new window) (workbox-build / workbox-webpack-plugin / workbox-cli).

In CI

jobs:
  workbox-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - run: npx playwright install --with-deps chromium
      - run: npx vitest run tests/precache-manifest.spec.ts
      - run: npx playwright test tests/workbox-recipes.spec.ts

The unit (Vitest) step gates fast; the Playwright recipe step catches the runtime-only regressions.

Parsing results

Workbox runtime caches are observable via three surfaces:

SurfaceWhat it showsHow to read
caches.keys()All cache namespaces (e.g. workbox-precache-v2, pages, images)sw.evaluate(() => caches.keys())
caches.open(name).keys()URLs cached in a namespaceFilter by URL pattern to assert what the recipe captured
Playwright page.on('request')Network egress per requestEmpty for cache-hit served paths = recipe working

When an assertion fails on the cache-content surface, also check the namespace name: Workbox v7 uses workbox-precache-v2 for precaching and recipe-default names (pages, images, static-resources, google-fonts-stylesheets, google-fonts-webfonts) for recipes unless overridden via cacheName option per wb-recipes (opens in new window).

CI integration

For projects that ship Workbox: lock both the precache manifest and one runtime recipe behavior per PR.

- name: Workbox unit + e2e
  run: |
    npm run build
    npx vitest run tests/precache-manifest.spec.ts
    npx playwright test tests/workbox-recipes.spec.ts

For projects that publish a service worker as part of a release artifact (separate from app deploy), gate the release on the same two steps - a Workbox regression that escapes to prod usually manifests as stale-forever or never-installed, both invisible without test coverage.

Anti-patterns

Anti-patternWhy it failsFix
Assert recipe behavior only on first page loadCache is empty; SW hasn't been installed yetPre-warm by visiting twice (or use await page.waitForLoadState('networkidle'))
Assert caches.keys() includes a fixed namespace namePer wb-recipes (opens in new window), default names can be overridden via cacheNameMatch by suffix substring (name.endsWith('-precache-v2'))
Use Vitest with a JSDOM-mocked caches for recipe behaviorJSDOM does not implement Cache Storage faithfully; ServiceWorkerRegistration is absentUse Playwright for runtime recipe assertions (Step 4)
Assume precacheAndRoute(self.__WB_MANIFEST) works without a bundler__WB_MANIFEST is injected at build time per wb-modules (opens in new window); CDN-served workbox-sw skips itIf using workbox-sw (CDN loader per wb-modules (opens in new window)), drop the precache assertion
Test 60-entry cap by checking caches.match returnsExpirationPlugin prunes async; tight await returns stale stateAdd await page.waitForTimeout(500) after the trigger (Step 4 imageCache() test)
Skip the workbox-window event tests entirelyThe page-side "update available" UX is built on these events; breaks silentlyStep 5 covers the five-event vocabulary

Limitations

  • Per-recipe defaults can drift across Workbox majors. The 60-image / 30-day / 1-year numbers cited above are the v7.x defaults per wb-recipes (opens in new window); consult the recipe page at the pinned Workbox version before treating the numbers as test invariants.
  • __WB_MANIFEST is build-tool-injected. Tests against the static dist/sw.js only pass when the bundler ran - local vitest run against src/sw.js will fail to find the array.
  • Cache Storage quota is browser-internal. Workbox's ExpirationPlugin maxEntries is asserted here; the browser's own quota (Step 4 of offline-fallback-tests) is a separate ceiling not testable from workbox-* alone.
  • workbox-window's waiting event only fires on update. A test that asserts waiting on first install will fail by design - see wb-modules (opens in new window) for the per-event firing conditions.
  • CDN-served workbox-sw sidesteps precaching entirely per wb-modules (opens in new window); this skill's Step 3 assertion does not apply to CDN-loader projects.

References

  • Workbox overview ("Production-ready service worker libraries and tooling") - wb-overview (opens in new window).
  • Workbox modules (per-package one-line descriptions; the workbox-precaching / workbox-window / workbox-routing / workbox-strategies / workbox-recipes family) - wb-modules (opens in new window).
  • Workbox recipes (pageCache, staticResourceCache, imageCache, googleFontsCache, offlineFallback, warmStrategyCache with defaults) - wb-recipes (opens in new window).
  • Workbox repo (v7.4.1 release, May 2026) - wb-gh (opens in new window).
  • Strategy design + authoring (per-route strategy table, audit of hand-rolled SWs, invalidation locking) - references/cache-strategy-design.md.
  • Generic context.serviceWorkers() Playwright patterns - the service-worker-lifecycle-tests references.
  • Sibling skills: offline-fallback-tests, service-worker-lifecycle-tests.

Choosing a cache strategy

View source (opens in new window)

Choosing a cache strategy

Design-side reference for workbox-tests: pick a cache strategy per route type (cache-first, network-first, stale-while-revalidate, cache-only, network-only) per Workbox conventions, author the strategy code, and generate the matching Playwright assertions to lock it in. Avoids the common "cached forever" pitfall by enforcing TTL + version-bump invalidation.

When to use

  • Designing offline behavior for a new PWA route.
  • Migrating from a hand-rolled SW to Workbox-style strategies.
  • Auditing an existing SW that "caches everything forever" - pick strategies per route type with TTL + invalidation.

Step 1 - Pick strategy per route type

Route typeStrategyWhy
Static immutable (/_next/static/, hashed filenames)CacheFirst with long TTLFilename change = cache key change; safe forever
HTML shell (/, /about)NetworkFirst with timeout fallbackAlways try network for fresh content; fallback to cache offline
API responses (/api/...)StaleWhileRevalidateShow cached now; refresh in background
User-specific data (/api/user/me)NetworkOnlyPrivacy; never cache
Manifest, robots.txtNetworkOnlyAlways reflect deploy state
Images (/img/*)CacheFirst with TTLBandwidth win; expire weekly
3rd-party fontsCacheFirst with long TTLLicense-permitting

Step 2 - Author Workbox-style strategy

// sw.js
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import {
  CacheFirst,
  NetworkFirst,
  StaleWhileRevalidate,
  NetworkOnly,
} from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';

const SW_VERSION = 'v3';

precacheAndRoute(self.__WB_MANIFEST);

// Static immutable
registerRoute(
  ({ url }) => url.pathname.startsWith('/_next/static/'),
  new CacheFirst({
    cacheName: `static-${SW_VERSION}`,
    plugins: [
      new ExpirationPlugin({ maxAgeSeconds: 60 * 60 * 24 * 365 }),
    ],
  })
);

// HTML shell
registerRoute(
  ({ request }) => request.destination === 'document',
  new NetworkFirst({
    cacheName: `html-${SW_VERSION}`,
    networkTimeoutSeconds: 3,
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
    ],
  })
);

// API
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/user/'),
  new StaleWhileRevalidate({
    cacheName: `api-${SW_VERSION}`,
    plugins: [
      new CacheableResponsePlugin({ statuses: [200] }),
      new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 5 }),
    ],
  })
);

// User data - never cache
registerRoute(
  ({ url }) => url.pathname.startsWith('/api/user/'),
  new NetworkOnly()
);

// Cleanup old SW versions on activate
self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((keys) =>
      Promise.all(
        keys
          .filter((k) => !k.endsWith(`-${SW_VERSION}`))
          .map((k) => caches.delete(k))
      )
    )
  );
});

Step 3 - Generate matching Playwright tests

For each registered route, emit one test asserting the strategy behavior. Pattern:

// Tests for sw.js routes - paired with the Step 2 strategy code above
import { test, expect } from '@playwright/test';

test.describe('SW cache strategies', () => {
  test('static assets: cache-first (offline still serves)', async ({ page, context }) => {
    await page.goto('https://localhost:3000');
    await page.waitForLoadState('networkidle');

    await context.setOffline(true);
    const resp = await page.evaluate(() =>
      fetch('/_next/static/abc123.css').then(r => r.status)
    );
    expect(resp).toBe(200);
  });

  test('HTML shell: network-first (fresh while online)', async ({ page }) => {
    let networkHits = 0;
    page.on('request', req => {
      if (req.url().endsWith('/about') && req.resourceType() === 'document') {
        networkHits++;
      }
    });

    await page.goto('https://localhost:3000/about');
    await page.goto('https://localhost:3000/');
    await page.goto('https://localhost:3000/about');

    expect(networkHits).toBeGreaterThanOrEqual(2);
  });

  test('user API: never cached', async ({ page, context }) => {
    await page.goto('https://localhost:3000');
    await page.evaluate(() => fetch('/api/user/me'));
    await page.waitForTimeout(100);

    let [sw] = context.serviceWorkers();
    const userCache = await sw.evaluate(async () => {
      const cache = await caches.open('api-v3');
      const reqs = await cache.keys();
      return reqs.map(r => r.url);
    });
    expect(userCache).not.toContain(expect.stringContaining('/api/user/'));
  });

  test('SW v3 deletes v2 caches on activate', async ({ context, page }) => {
    await page.goto('https://localhost:3000');
    let [sw] = context.serviceWorkers();
    const cacheNames = await sw.evaluate(() => caches.keys());

    expect(cacheNames.every((n: string) => n.endsWith('-v3'))).toBe(true);
  });
});

Step 4 - Audit existing SW

For each caches.match / event.respondWith block in an existing SW, classify:

Existing patternLikely categoryMigration
caches.match(req).then(r => r ?? fetch(req))CacheFirst (no TTL)Add ExpirationPlugin
fetch(req).catch(() => caches.match(req))NetworkFirst (no timeout)Add networkTimeoutSeconds
caches.match(req) onlyCacheOnly (dangerous for HTML)Verify intentional
Hand-rolled SWR (parallel fetch + cache.put)StaleWhileRevalidateReplace with Workbox class

Step 5 - Lock invalidation strategy

Bumping SW_VERSION is the most reliable invalidation, but breaks revalidation for users with stale tabs. Pair with:

  • Trigger skipWaiting() only after user opt-in (banner: "New version available - refresh").
  • For HTML shell, prefer NetworkFirst with networkTimeoutSeconds: 3 over CacheFirst (so users see updated UI as soon as network allows).

Anti-patterns

Anti-patternWhy it failsFix
CacheFirst without TTLStale forever; users on stale UI for weeksAlways ExpirationPlugin (Step 2)
Cache POST/PUT/DELETE responsesSide effects replayed; data corruptionStrategies only match GET by default; verify in fetch handler
Cache Set-Cookie responsesCross-user cookie leakCacheableResponsePlugin({ statuses: [200] }) excludes; never cache user-specific
Auto skipWaiting on every deployUsers mid-form lose stateRequire user opt-in (Step 5)
One cache name "app-cache" foreverOld assets stay foreverVersion per release (Step 2 SW_VERSION)

Limitations

  • Workbox v7+ requires bundling (Vite/webpack/Rollup); CDN-served SW patterns are limited.
  • Some browsers (Firefox) have stricter SW cache quota; test the worst-case browser for your audience.
  • This reference is JS/TS-first; no equivalent for Dart/Flutter PWA SWs.

References

  • Workbox docs (opens in new window) - authoritative API + plugin reference (consult for current ExpirationPlugin / CacheableResponsePlugin signatures)
  • service-worker-lifecycle-tests - sister skill providing the SW state-machine spec plus the general Playwright SW harness in its references/

Per-recipe runtime test templates

View source (opens in new window)

Per-recipe runtime test templates

Companion detail for workbox-tests. The imageCache() 60-entry-cap test is the representative core inline in Step 4; these are the other five recipe templates. Each retains the recipe's test-invariant default (timeout, entry count, TTL) but drops the verbatim doc prose. Defaults are the Workbox v7.x values per wb-recipes - re-pin at the recipe page on a major upgrade.

pageCache()

Network-first for HTML navigations with a 3-second networkTimeoutSeconds default - cache serves once network exceeds the timeout:

import { test, expect } from '@playwright/test';

test('pageCache() falls back to cache when network exceeds 3s', async ({ page, context }) => {
  await page.goto('https://localhost:3000/');
  await page.waitForLoadState('networkidle');

  // Slow the network past the 3s networkTimeoutSeconds default
  await context.route('**/*.html', async route => {
    await new Promise(r => setTimeout(r, 5_000));
    await route.continue();
  });

  await page.goto('https://localhost:3000/');
  // Cached shell should serve before the 5s slow network resolves
  await expect(page.locator('h1')).toBeVisible({ timeout: 4_500 });
});

offlineFallback()

Serves the offline.html default on a navigation routing error while offline (swap the target if the project overrides pageFallback):

test('offlineFallback() serves offline.html on navigation failure', async ({ page, context }) => {
  await page.goto('https://localhost:3000/');
  await page.waitForLoadState('networkidle');

  await context.setOffline(true);
  const resp = await page.goto('https://localhost:3000/never-cached');
  expect(resp?.status()).toBe(200);
  await expect(page.locator('text=/offline/i')).toBeVisible();
});

googleFontsCache()

Stale-while-revalidate for stylesheets, cache-first for font files, with defaults of 30 font files cached for one year:

test('googleFontsCache stylesheet uses stale-while-revalidate', async ({ page, context }) => {
  await page.goto('https://localhost:3000/');
  await page.waitForLoadState('networkidle');

  await context.setOffline(true);
  const status = await page.evaluate(() =>
    fetch('https://fonts.googleapis.com/css2?family=Inter').then(r => r.status).catch(() => 0)
  );
  // Stale cache must respond offline
  expect(status).toBe(200);
});

staticResourceCache()

Stale-while-revalidate for CSS, JavaScript, and Web Worker requests:

test('staticResourceCache serves cached CSS offline', async ({ page, context }) => {
  await page.goto('https://localhost:3000/');
  await page.waitForLoadState('networkidle');
  await context.setOffline(true);

  const status = await page.evaluate(() =>
    fetch('/styles/app.css').then(r => r.status).catch(() => 0)
  );
  expect(status).toBe(200);
});

warmStrategyCache()

Loads the declared URL list into the cache during the SW install phase - pin which URLs are warmed:

test('warmStrategyCache() warms the declared URL list on install', async ({ context, page }) => {
  await page.goto('https://localhost:3000/');
  let [sw] = context.serviceWorkers();
  if (!sw) sw = await context.waitForEvent('serviceworker');

  // SW install phase warms a known URL - pin it
  const warmed = await sw.evaluate(async () => {
    const names = await caches.keys();
    for (const n of names) {
      const cache = await caches.open(n);
      const keys = await cache.keys();
      if (keys.some(k => k.url.endsWith('/critical-data.json'))) return true;
    }
    return false;
  });
  expect(warmed).toBe(true);
});

Source reference:

  • wb-recipes: https://developer.chrome.com/docs/workbox/modules/workbox-recipes

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.

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.

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.