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.
Install with skills.sh (any agent)
npx skills add testland/qa --skill offline-fallback-testsoffline-fallback-tests
Overview
Offline behavior fragments into eight named recipes per Jake Archibald's offline cookbook off-cookbook (opens in new window) - and most PWAs use three or four of them across different routes. A test suite that asserts "the site works offline" misses the per-route recipe contract: a route on Cache only must serve from cache even when network is fine; a route on Network falling back to cache must prefer network when both are present.
This skill emits the per-route test suite. The output is a Playwright spec file plus a coverage YAML that maps each URL pattern to its recipe and its assertion. The suite is distinct from the strategy authoring covered by workbox-tests (references/cache-strategy-design.md); that side authors the strategy, this builder generates the verification.
Composes with:
When to use
Workflow
Step 1 - Inventory routes and pick a recipe per route
For each URL pattern the SW intercepts, decide which of the eight named recipes per off-cookbook (opens in new window) applies. The cookbook recipes verbatim:
| Recipe (per off-cookbook (opens in new window)) | When to use (quoted) |
|---|---|
| Cache only | "Use for static assets you've cached during installation that your site depends on." |
| Network only | "Best for requests without offline equivalents, like analytics or non-GET operations." |
| Cache, falling back to network | "Ideal for building offline-first apps where cached content serves as the default." |
| Cache and network race | "Useful when disk access is slow and network speed might win the competition for small assets." |
| Network falling back to cache | "Good for frequently updating resources, though slow connections create user frustration." |
| Cache then network | "Perfect for content that changes often; displays cached data immediately, updates when fresh data arrives." |
| Generic fallback | "Provides a default response when both cache and network requests fail for secondary content." |
| Service worker-side templating | "Combines cached templates with JSON data to render pages the service worker controls." |
Workbox layers a named composite recipe on top per wb-recipes (opens in new window):
Capture decisions in a YAML manifest:
# tests/offline-recipe-matrix.yaml
routes:
- pattern: /_next/static/**
recipe: Cache only
reason: Hashed filenames, immutable
- pattern: /
recipe: Network falling back to cache
reason: HTML shell - fresh while online, stale offline
- pattern: /api/feed
recipe: Cache then network
reason: List view; show fast, update behind
- pattern: /api/user/me
recipe: Network only
reason: PII; never cache
- pattern: /img/**
recipe: Cache, falling back to network
reason: Bandwidth save; image lifetime ≥ cache TTL
- pattern: /*
recipe: Generic fallback
reason: Catch-all 404; offline.htmlStep 2 - Emit the per-recipe Playwright test
For each recipe row in Step 1's matrix, emit the matching test - the assertion shape must distinguish "served from cache" vs "served from network." The two poles (cache-always vs network-always) are inline; the rest live in the reference.
Cache only
Serves from cache even with network reachable AND with network blocked:
test('Cache only: /_next/static/x.css serves from cache regardless of network', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
// Block network for the static path
await context.route('**/_next/static/**', route => route.abort('failed'));
const res = await page.evaluate(() =>
fetch('/_next/static/x.css').then(r => r.status).catch(() => 0)
);
expect(res).toBe(200);
});Network only
test('Network only: /api/user/me fails when offline (never cached)', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await context.setOffline(true);
const res = await page.evaluate(() =>
fetch('/api/user/me').then(r => r.status).catch(() => 0)
);
expect(res).toBe(0); // network error; not served from cache
});The remaining six recipe templates - Cache falling back to network, Cache and network race, Network falling back to cache, Cache then network, Generic fallback, and SW-side templating - are in references/recipes.md, each with its assertion-shape note.
Step 3 - Test the underlying storage choice
Per off-data (opens in new window), offline data needs the right storage:
| Storage | Per off-data (opens in new window) | Test posture |
|---|---|---|
| Cache Storage API | "Designed for network resources accessed via URL (HTML, CSS, JavaScript, images, videos, audio)" | Assert caches.has(name) + caches.open(name).keys() returns expected URLs |
| IndexedDB | "Use IndexedDB to store structured data" - "data that needs to be searchable or combinable in a NoSQL-like manner" | Assert IDB schema + key existence via indexedDB.open() |
| Storage Manager | navigator.storage.estimate() returns { quota, usage }; navigator.storage.persist() requests durable storage | Assert persisted() returns true post-request |
Test the Storage Manager persist() request:
test('Storage Manager persist() succeeds for installed PWA', async ({ page }) => {
await page.goto('https://localhost:3000/');
const result = await page.evaluate(async () => {
const persisted = await navigator.storage.persist();
const isPersisted = await navigator.storage.persisted();
return { persisted, isPersisted };
});
expect(result.isPersisted).toBe(true);
});Per off-data (opens in new window): "Chrome permits origins to use up to 60% of total disk space." Tests that assume small caches mask quota-pressure bugs; assert the application stays well below the estimate:
test('cache usage stays under 50MB', async ({ page }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
const est = await page.evaluate(async () => navigator.storage.estimate());
expect(est.usage!).toBeLessThan(50 * 1024 * 1024);
});Step 4 - Test the offline-fallback page itself
Per wb-recipes (opens in new window), offlineFallback() defaults to offline.html. Assert the file is precached + the response shape:
test('offline.html is precached and well-formed', async ({ page, context }) => {
await page.goto('https://localhost:3000/');
await page.waitForLoadState('networkidle');
let [sw] = context.serviceWorkers();
if (!sw) sw = await context.waitForEvent('serviceworker');
const offlinePagePresent = await sw.evaluate(async () => {
for (const name of await caches.keys()) {
const c = await caches.open(name);
const found = await c.match('/offline.html') || await c.match('/_offline');
if (found) return found.status;
}
return null;
});
expect(offlinePagePresent).toBe(200);
});If the project overrides the default pageFallback option, swap the assertion target.
Step 5 - Test the offline → online recovery
A subtle class of bugs: a route correctly serves from cache offline, but doesn't refresh when network returns. Test the transition:
test('cache then network: recovers fresh data when online returns', async ({ page, context }) => {
await page.goto('https://localhost:3000/feed');
await context.setOffline(true);
await page.reload();
// Stale cache served
await context.setOffline(false);
// Trigger refresh
await page.evaluate(() => fetch('/api/feed').then(r => r.json()));
await page.waitForTimeout(500);
// Assert no "offline" banner lingering
await expect(page.locator('[data-testid="offline-banner"]')).not.toBeVisible();
});Step 6 - Emit the coverage matrix and CI gate
Write tests/offline-coverage.yaml mapping each route from Step 1 to its test name and the source cookbook recipe:
matrix:
- route: /_next/static/**
recipe: Cache only
spec: offline-fallback.spec.ts > 'Cache only: /_next/static/*'
source: off-cookbook "Cache only"
- route: /
recipe: Network falling back to cache
spec: offline-fallback.spec.ts > 'Network falling back to cache'
source: off-cookbook "Network falling back to cache"
- route: /api/feed
recipe: Cache then network
spec: offline-fallback.spec.ts > 'Cache then network: /api/feed'
source: off-cookbook "Cache then network"
- route: /api/user/me
recipe: Network only
spec: offline-fallback.spec.ts > 'Network only: /api/user/me'
source: off-cookbook "Network only"
- route: /img/**
recipe: Cache, falling back to network
spec: offline-fallback.spec.ts > 'Cache, falling back to network: /img/*'
source: off-cookbook "Cache, falling back to network"
- route: /*
recipe: Generic fallback
spec: offline-fallback.spec.ts > 'Generic fallback'
source: off-cookbook "Generic fallback"; wb-recipes offlineFallback()
storage:
- check: cache-usage-under-budget
source: off-data Storage Manager estimate()
- check: persisted-storage-granted
source: off-data Storage Manager persist()CI gates on every row having a passing spec.
Worked example: a 4-route news PWA
Applying Step 1's matrix to a news PWA - static -> Cache only, / -> Network falling back to cache, /api/articles -> Cache then network, /api/user/me -> Network only, /* -> Generic fallback - emits a 5-cell Playwright spec that runs in ~15 seconds on Chromium and catches the four most common offline regressions:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| One "site works offline" smoke test | Per-route recipe regressions invisible | One spec per recipe row per off-cookbook (opens in new window) (Step 2) |
| Test offline by killing the dev server | SW caches still serve from network until setOffline(true) is called | Always context.setOffline(true) (every cell) |
| Skip the offline → online recovery test | Stale data lingers after network returns; users see "offline" banner online | Step 5 |
Pin caches.match('/offline.html') to a hard URL | Workbox pageFallback option overrides; tests false-fail on custom configs | Match by suffix (Step 4) |
| Test only Chromium | Firefox cache behavior + quota differ | Run cross-browser; Firefox storage.persist() UX differs |
Hand-build storage assertions instead of navigator.storage.estimate() | Misses quota-pressure | Step 3 uses the API |
| Assume cached HTML stays fresh forever | TTL plugin (or its absence) gates this; per wb-recipes (opens in new window) imageCache() defaults to 30 days | Assert TTL via imageCache-style entry expiration in workbox-tests |
| Mix recipes without documenting per route | New routes inherit wrong default | The matrix YAML (Step 1) is the source of truth |
Limitations
References
Per-recipe Playwright test templates
View source (opens in new window)Per-recipe Playwright test templates
Companion detail for offline-fallback-tests. The two poles (Cache only, Network only) are inline in Step 2; these are the remaining six recipe templates from the offline cookbook. Each recipe's "when to use" is quoted once in the SKILL.md Step 1 table - here only the assertion shape is noted.
Cache, falling back to network
Serves from cache when present, network when not:
test('Cache, falling back to network: /img/x.png serves from cache when present, network when not', async ({ page, context }) => {
// Warm
await page.goto('https://localhost:3000/img/x.png');
await page.waitForLoadState('networkidle');
// Now offline - cache hits
await context.setOffline(true);
const offline = await page.evaluate(() =>
fetch('/img/x.png').then(r => r.status).catch(() => 0)
);
expect(offline).toBe(200);
});Cache and network race
The first responder wins. Assert the call returns under a small-disk-read budget:
test('Cache and network race: response under 100ms when cached', async ({ page }) => {
await page.goto('https://localhost:3000/api/race-cache');
await page.waitForLoadState('networkidle');
const ms = await page.evaluate(async () => {
const t = performance.now();
await fetch('/api/race-cache');
return performance.now() - t;
});
expect(ms).toBeLessThan(100);
});Network falling back to cache
Prefers network when online, falls back to cache when offline:
test('Network falling back to cache: / serves fresh when online, cached when offline', 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/');
expect(resp?.status()).toBe(200);
await context.setOffline(false);
// When online, network is preferred - track that a request fires
let hits = 0;
page.on('request', req => { if (req.url().endsWith('/')) hits++; });
await page.reload();
expect(hits).toBeGreaterThanOrEqual(1);
});Cache then network
Two responses for one fetch: instant cache, then revalidated network. Assert two paint phases:
test('Cache then network: /api/feed renders cached list first, refreshes on network', async ({ page }) => {
await page.goto('https://localhost:3000/feed');
// Initial render uses cached list
await expect(page.locator('[data-testid="feed-list"]')).toBeVisible();
const initialCount = await page.locator('[data-testid="feed-item"]').count();
// Refresh fetches from network with newer items
await page.evaluate(() => fetch('/api/feed?force-refresh=1'));
await page.waitForTimeout(500);
const updated = await page.locator('[data-testid="feed-item"]').count();
expect(updated).toBeGreaterThanOrEqual(initialCount);
});Generic fallback
Unknown route while offline serves offline.html (Workbox offlineFallback() default):
test('Generic fallback: unknown route + offline serves offline.html', 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-existed');
expect(resp?.status()).toBe(200);
await expect(page.locator('text=/offline|unavailable/i')).toBeVisible();
});Service Worker side templating
Assert the SW synthesizes the response from a cached shell + JSON data:
test('SW-side templating: /reports/123 renders cached shell + JSON data', async ({ page }) => {
// The SW's fetch handler must compose template + data
await page.goto('https://localhost:3000/reports/123');
await expect(page.locator('h1')).toContainText('Report #123');
// Distinct origin proves SW synthesis (not pure network)
const src = await page.evaluate(() => navigator.serviceWorker.controller?.scriptURL);
expect(src).toBeTruthy();
});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.
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.
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.