| name | playwright-app-tests |
| description | Playwright end-to-end tests pointed at the running snapmatch app — route workflows under `apps/example-game/tests/app/`, real IDB hydration via `page.evaluate`, BroadcastChannel cross-tab verification, IDB-seeded fixtures (no mocks). Triggers on: playwright app test, e2e workflow, app-level test, route workflow test, idb seeded test, idb assertion. |
| license | MIT |
Sub-skill of playwright. Owns Playwright tests pointed at the running app (Vite dev or the static bun run preview server), not at Storybook. Verifies end-to-end route workflows: navigate, interact, assert visible state, assert real IDB content, observe BroadcastChannel sync. Anything that needs a real browser + real IndexedDB but is bigger than a single story belongs here.
When to invoke
- Writing a Playwright spec that drives a real route in
apps/example-game/app/routes/....
- Asserting on a route's hydrated content (post-
use(idbHydrationPromise) Suspense resolution).
- Verifying that a write from one tab triggers re-hydration in another (
BroadcastChannel).
- Seeding IDB before navigation so the route under test sees pre-existing user state.
- Running an integration smoke that exercises a route end-to-end (open → interact → save → reload → still saved).
Owns
Playwright end-to-end tests pointed at the built/preview app: route workflows, IDB-seeded fixtures, BroadcastChannel verification.
Defers to
playwright (parent) — version pin and routing.
playwright-conventions — the ASK-FIRST rule, selector strategy, fixture patterns (fresh IDB, seeded IDB, throttled), reduced-motion config, the playwright.config.ts shape.
playwright-pwa-offline — for any test that throttles network to offline. The offline contract is that sub-skill's surface; this one stays online (or merely throttled).
tanstack-router-routing — for the route URLs and the routing contract this test workflow exercises. Param schemas live there (and in zod).
idb — for the IDB schema + record shapes Playwright reads/writes against, and the root idbHydrationPromise the app awaits at startup.
bun-test — for unit-testable logic that does NOT need a browser. The unit/E2E partition is hard.
storybook-stories / playwright-story-tests — for any logic that's better verified at the component level. Build small first; reach for app-level only when the workflow needs multiple components composed together.
Snapmatch stack rules
- ASK FIRST before writing or modifying any Playwright test. Pillar 4 manifestation; canonical detail in
playwright-conventions. Surface the structural choices (which route, what to assert, IDB seed, network state, reduced-motion) and wait for the user's answer.
- Pillar 3 is implemented in application code and the Auth/Firestore Emulator suites: Firestore
snapshot → Zod parse → revision-aware IDB commit → Jotai, plus durable command → IDB outbox →
Firestore → exact receipt reconciliation. Existing app Playwright coverage still proves only the
generated progress/settings seed against real IDB. Browser convergence, the v3-to-v4 compound-key
upgrade, the v4-to-v5 retirement-intent upgrade/reload barrier, offline recovery, and multi-tab
identity scenarios remain owner-approved future coverage; do not infer them from Bun or Emulator
tests.
- Pillar 4 (CLI-gate-first) means: a failing app test fails
bun run check. App-level tests are CI-only — the pre-push hook runs bun run check:fast which restricts Playwright to --project=storybook (vite preview against dist/ would re-validate yesterday's bytes locally; not meaningful). Never test.skip to make CI green; fix the workflow or the wiring.
- Web-first assertions only —
await expect(locator).toBe…(). No point-in-time queries inside expect().
- Reduced motion is forced on at the project level (see
playwright-conventions); animations don't add flake.
- IDB is never mocked. Use real browser IndexedDB; seed via
page.addInitScript; read via page.evaluate.
- App tests run against
bun run preview (the same dist/client static artifact Firebase Hosting serves), not bun run dev, in CI. Inner-loop debugging may use dev; GitHub Pages is an optional secondary static target.
Patterns
ASK FIRST before writing or modifying any Playwright test in this sub-skill — see playwright-conventions for the canonical rule. Surface the structural decisions (which route(s), what to assert, IDB seed, network state, reduced-motion); wait for the user's answer before writing.
Project pinned to the app
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
projects: [
{
name: "app",
testMatch: /.*\.app\.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,
},
],
});
App tests run against bun run preview (the static prerender output served locally). For development inner-loop feedback, point webServer.command at bun run dev instead — but CI should use preview so the test exercises the same artifact Firebase Hosting serves.
Minimal route-workflow test
import { expect, test } from "@playwright/test";
test("a player can complete sample level 1 and progress is saved", async ({ page }) => {
await page.goto("/games/sample/1");
await expect(page.getByRole("heading", { name: /level 1/i })).toBeVisible();
await page.getByRole("button", { name: /complete/i }).click();
await expect(page.getByText(/saved/i)).toBeVisible();
await page.reload();
await expect(page.getByText(/level 1.*completed/i)).toBeVisible();
});
This reload-and-verify check covers the generated app's current IDB-local progress seed. It remains valuable after remote state lands, but it does not by itself prove Firestore authority, outbox delivery, or reconciliation.
Asserting on real IDB content
test("save writes a row to the progress object store", async ({ page }) => {
await page.goto("/games/sample/1");
await page.getByRole("button", { name: /complete/i }).click();
await expect(page.getByText(/saved/i)).toBeVisible();
const rows = await page.evaluate(async () => {
const open = indexedDB.open("snapmatch");
return await new Promise<unknown[]>((resolve, reject) => {
open.onsuccess = () => {
const tx = open.result.transaction("progress", "readonly");
const req = tx.objectStore("progress").getAll();
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
};
open.onerror = () => reject(open.error);
});
});
expect(rows).toContainEqual({ id: "sample-1", level: 1, completed: true });
});
page.evaluate runs in the browser context — the assertion sees the same indexedDB the app writes to. Never reach for an idb import in the spec; the spec runs in Node, IDB is a browser API.
Seeded-IDB fixture (canonical pattern lives in playwright-conventions)
import { expect, test as base } from "@playwright/test";
const test = base.extend<{ seededPage: typeof base extends never ? never : Awaited<ReturnType<typeof base["page"]["fixture"]>> }>({
seededPage: async ({ page }, runFixture) => {
await page.addInitScript(() => {
const open = indexedDB.open("snapmatch", 2);
open.onupgradeneeded = () => {
open.result.createObjectStore("progress", { keyPath: "id" });
open.result.createObjectStore("settings", { keyPath: "id" });
};
open.onsuccess = () => {
const tx = open.result.transaction(["progress"], "readwrite");
tx.objectStore("progress").put({ id: "sample-1", level: 1, completed: true });
};
});
await runFixture(page);
},
});
test("a previously-completed level shows its completed state on first paint", async ({ seededPage: page }) => {
await page.goto("/games/sample/1");
await expect(page.getByText(/level 1.*completed/i)).toBeVisible();
});
page.addInitScript executes before any page script — it lands the seeded IDB rows before the app's idbHydrationPromise resolves. See playwright-conventions for the canonical fixture file.
BroadcastChannel — verify cross-tab re-hydration
test("a write in tab A re-hydrates tab B via BroadcastChannel", async ({ context }) => {
const a = await context.newPage();
const b = await context.newPage();
await a.goto("/games/sample/1");
await b.goto("/games/sample/1");
await a.getByRole("button", { name: /complete/i }).click();
await expect(a.getByText(/saved/i)).toBeVisible();
await expect(b.getByText(/level 1.*completed/i)).toBeVisible();
});
context.newPage() shares a BrowserContext (and therefore the BroadcastChannel) across pages. See idb for the channel name (snapmatch:idb) and the message shape.
Throttled-network test (still online)
test("interaction during slow network still saves to IDB", async ({ page, context }) => {
await context.route("**/*", async (route) => {
await new Promise((r) => setTimeout(r, 200));
await route.continue();
});
await page.goto("/games/sample/1");
await page.getByRole("button", { name: /complete/i }).click();
await expect(page.getByText(/saved/i)).toBeVisible();
});
For the intentionally deferred offline scenarios (SW + deep-link resolution), see playwright-pwa-offline; do not unskip them before the build actually emits a Workbox service worker.
Remote-first browser coverage must exercise Firestore Rules and multi-client convergence through the
Emulator Suite rather than mocking the SDK. The non-browser emulator baseline is currently 35 of 35
Rules cases plus one live Web SDK test with 280 assertions. Keep any production smoke bounded to the
immutable connectivity sentinel until domain rollout is approved; the sentinel is not domain-state
synchronization.
Run the workflow
bun run test:e2e -- --project=app
bun run test:e2e -- --project=app --grep @smoke
bun run test:e2e -- --project=app sample-level
Anti-patterns
- Don't write a Playwright test without ASKING FIRST — see
playwright-conventions. The user owns the structural decisions per case.
- Don't mock IDB — Playwright drives a real browser with real IndexedDB. Seed with
page.addInitScript (see playwright-conventions); read with page.evaluate.
- Don't reach for an
idb import in the spec — the spec runs in Node. The browser is where IDB lives; use page.evaluate.
- Don't insert
page.waitForTimeout(...) — fix the locator/assertion. Web-first assertions retry; fixed sleeps mask real flake.
- Don't write an app-level test for behavior that's already covered by a story-level test — story-level is faster and more focused. Reach for app-level when the workflow spans multiple components or routes.
- Don't assert with point-in-time queries inside
expect() — expect(await locator.isVisible()).toBe(true) doesn't retry. Use await expect(locator).toBeVisible().
- Don't run app tests against
bun run dev in CI — use bun run preview so the artifact under test is the same one Firebase Hosting serves. Dev-mode HMR can mask production bugs.
- Don't hand-write IDB seed shapes — derive seed objects from the same Zod schemas the app uses (see
idb and zod). Hand-written seeds drift from the schema.
Triggers on
playwright app test, e2e workflow, app-level test, route workflow test, idb seeded test, idb assertion