| name | playwright-pwa-offline |
| description | The future offline-deep-link Playwright contract for snapmatch — currently skipped until the build emits a Workbox service worker; once enabled, install the SW, go offline with `context.setOffline(true)`, deep-link to a route the prerender did NOT emit, and assert the router resolves it from cache. Triggers on: offline test, pwa offline test, deep link offline test, network offline, service worker test, navigation fallback test. |
| license | MIT |
Sub-skill of playwright. Owns the reference contract for a future offline-deep-link test. The generated app does not currently emit a Workbox service worker, and shell.offline.spec.ts is intentionally skipped with that reason. Once PWA work is explicitly enabled, a previously installed service worker must return the prerendered SPA shell from cache, TanStack Router must resolve an arbitrary deep URL client-side, and the app must hydrate its local replica while context.setOffline(true) is active.
When to invoke
- Implementing the offline-deep-link Playwright spec after Workbox output is authorized and present.
- Reviewing the intentionally skipped shell spec without pretending the offline contract is active today.
- Diagnosing "the offline test fails on a fresh browser" (the SW isn't installed yet — first visit must go online to install).
- Wiring
context.setOffline(true) plus page.route() to make the test airtight.
- Verifying the navigation-fallback contract from
tanstack-router-pwa-deep-links end-to-end.
- Adding a new offline scenario (a different deep route, a route the prerender skipped).
Owns
The future offline-deep-link contract: install the emitted service worker, throttle network to offline, navigate to /some/deep/route, and assert the router resolves it from cache without a server round-trip. Until Workbox exists, this skill owns the reference design and the explicit skip rationale.
Defers to
playwright (parent) — version pin and routing.
playwright-conventions — the ASK-FIRST rule, selector strategy, fixture patterns, reduced-motion config, the playwright.config.ts shape.
tanstack-router-pwa-deep-links — for what the contract is: the navigation fallback points at the prerendered shell, the router resolves the URL client-side. This sub-skill verifies; that one defines.
tanstack-start-spa-prerender — for the prerendered shell that the SW caches.
nitro — for the dist/client static output Firebase Hosting serves and its SPA fallback.
idb — for local progress/settings and the implemented durable shared replica/outbox. Jotai
projects reconciled IDB state; the service worker never owns domain data.
playwright-app-tests — for the online sibling tests (route workflows that don't toggle offline).
Snapmatch stack rules
- ASK FIRST before writing or modifying any Playwright test. Pillar 4 manifestation; canonical detail in
playwright-conventions. The offline test has more dials than most (when to install the SW, what to seed in IDB, which routes to verify) — surface the choices and wait for the user's answer.
- Current status: the
app-offline project exists, but its shell test remains intentionally skipped because no Workbox service worker is emitted. Do not describe the PWA contract as implemented or load-bearing yet. Once enabled, the project belongs in the full bun run check (CI), not bun run check:fast, because it needs a fresh production dist/.
- Pillar 3's target choreography still applies offline: committed shared state is Firestore-authoritative, IDB is the durable replica/pending-command outbox, and Jotai is the reactive optimistic projection. Future tests must prove queued commands and later reconciliation; today's skipped shell test and local progress/settings seed do not prove that behavior.
- The service worker NEVER touches IDB — assets only. IDB is application code (see
idb).
- The fallback always points at the canonical prerendered shell (see
tanstack-router-pwa-deep-links), never at per-route HTML.
- Once enabled, the offline test must run against
bun run preview — Vite dev does NOT register the production SW.
- Reduced motion is forced on at the project level (see
playwright-conventions); animations don't add flake.
- Web-first assertions only;
context.setOffline(true) is paired with a page.route() backstop so leaks fail loud.
Reference patterns for when Workbox lands
ASK FIRST before writing or modifying any Playwright test in this sub-skill — see playwright-conventions for the canonical rule. The offline test has more dials than most (when to install the SW, what to seed in IDB, which routes to verify); surface the choices and wait for the user's answer before writing.
Project pinned to the preview server
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
projects: [
{
name: "app-offline",
testMatch: /.*\.offline\.spec\.ts/,
use: {
...devices["Desktop Chrome"],
baseURL: "http://localhost:3000",
reducedMotion: "reduce",
},
},
],
webServer: [
{
command: "bun run preview",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
],
});
The offline test cannot run against bun run dev — Vite's dev server doesn't register the production SW. Always bun run preview.
The canonical offline-deep-link spec (not active yet)
import { expect, test } from "@playwright/test";
test("a previously-visited app resolves a deep URL while offline", async ({ page, context }) => {
await page.goto("/");
await page.waitForFunction(async () => {
if (!("serviceWorker" in navigator)) return false;
const reg = await navigator.serviceWorker.getRegistration();
return reg?.active?.state === "activated";
});
await page.evaluate(async () => {
const open = indexedDB.open("snapmatch");
await new Promise((res, rej) => {
open.onsuccess = () => {
const tx = open.result.transaction("progress", "readwrite");
tx.objectStore("progress").put({ id: "sample-1", level: 1, completed: true });
tx.oncomplete = () => res(undefined);
tx.onerror = () => rej(tx.error);
};
open.onerror = () => rej(open.error);
});
});
await context.setOffline(true);
await page.route("**/*", async (route) => {
const url = new URL(route.request().url());
if (url.origin !== new URL(page.url()).origin) {
await route.abort();
} else {
await route.continue();
}
});
await page.goto("/games/sample/1");
await expect(page.getByRole("heading", { name: /level 1/i })).toBeVisible();
await expect(page.getByText(/level 1.*completed/i)).toBeVisible();
});
Once the service worker exists and the test is unskipped, all six steps are required: install SW → seed IDB → toggle offline → belt-and-braces network blocker → deep-link → assert.
Why both setOffline(true) AND page.route()?
context.setOffline(true) simulates a fully offline network — the browser doesn't issue requests to the OS. But edge cases (subresource hints, beacon-style sends, plugin-injected requests) can sometimes leak. page.route('**/*', ...) is the airtight backstop: any request that escapes goes through the route handler, which aborts anything cross-origin and lets same-origin pass to the SW. If a route fails offline because of a request that should have been cached, you'll see it in the trace as an aborted request — easier to diagnose than "the page is white."
Verifying the SW returned the shell (not a 404)
const navPromise = page.waitForResponse((r) => r.url() === "http://localhost:3000/games/sample/1");
await page.goto("/games/sample/1");
const nav = await navPromise;
expect(nav.status()).toBe(200);
expect(nav.headers()["content-type"]).toContain("text/html");
Useful when debugging "did the SW even handle this navigation?" Once the test is stable, the visible-content assertion (expect(...).toBeVisible()) is enough.
Service-worker installation timing
await page.waitForFunction(
async () => {
if (!("serviceWorker" in navigator)) return false;
const reg = await navigator.serviceWorker.getRegistration();
if (!reg) return false;
if (reg.active?.state === "activated") return true;
if (reg.waiting) reg.waiting.postMessage({ type: "SKIP_WAITING" });
return false;
},
null,
{ timeout: 10_000 },
);
Use this when the test runs on a slow CI runner — the SW lifecycle can take a few seconds.
A second-load offline scenario
test("hard reload while offline still resolves the deep URL", async ({ page, context }) => {
await context.setOffline(true);
await page.goto("/games/sample/1");
await expect(page.getByRole("heading", { name: /level 1/i })).toBeVisible();
await page.reload();
await expect(page.getByRole("heading", { name: /level 1/i })).toBeVisible();
});
Remote reconciliation is implemented, but Workbox and its owner-approved browser coverage are not.
Once they land, reload-while-offline becomes the iPad-asleep-overnight scenario from AGENTS.md.
Passing the current IDB-only reference example would not by itself prove queued Firestore commands
converge after reconnection.
Run the workflow
bun run test:e2e -- --project=app-offline
bun run test:e2e -- --project=app-offline sample-deep
Anti-patterns
- Don't write a Playwright test without ASKING FIRST — see
playwright-conventions. The user owns the structural decisions per case.
- Don't run the offline test against
bun run dev — Vite dev doesn't register the production SW. Always bun run preview.
- Don't toggle offline before the SW is installed — the first visit must go online so Workbox can precache the shell. Test the SW lifecycle, then go offline.
- Don't use ONLY
context.setOffline(true) — pair it with a page.route('**/*', ...) backstop so leaks fail loud, not silent.
- Don't pick a route the prerender DID emit — that's a weaker test (the SW might fall back to the per-route HTML, not exercise the navigation fallback). Pick a deep route the SW must answer with the shell.
- Don't precache per-route data — see
tanstack-router-pwa-deep-links and idb. The SW handles
assets; application code owns local seed state and the implemented IDB replica/outbox, while
Firestore remains the shared source of truth.
- Don't put IDB reads inside the SW — the SW context can't see the app's atoms. Application code reads IDB.
- Don't unskip the shell test before the production build emits a service worker. The current skip documents an unimplemented capability. After the feature lands, remove the skip and fix any Workbox/routing regression instead of disabling the active contract.
- Don't insert
page.waitForTimeout(...) to "wait for the SW" — use waitForFunction against navigator.serviceWorker.getRegistration().
Triggers on
offline test, pwa offline test, deep link offline test, network offline, service worker test, navigation fallback test