| name | pinpoint-e2e |
| description | E2E testing guide for PinPoint (Playwright, Isolation, Mailpit, Supabase). Use when writing, debugging, or fixing E2E tests to ensure worker isolation and stability. |
PinPoint E2E Testing Skill
This skill guides you through the E2E testing infrastructure of PinPoint.
Before Writing an E2E Test (30-Second Pre-Flight)
Run this checklist BEFORE typing test("...", async ({ page }). The 2026-05 audit found that ~3/4 of new E2E specs that get filed are misallocated and could land at a cheaper, faster, more reliable layer.
- What bug class does this catch? See
pinpoint-testing skill § "Bug Classes & Cheapest Catching Layer". Class A/B/C/E/G/H/I should not be E2E — integration or RTL unit is the right home.
- Does an integration or unit test already cover this feature? Run
rg -l "your-keyword" src/test/ src/ first (substitute the actual feature keyword). The audit found that agents create new specs because they can't see the existing tests — most of the time you should be extending an existing file, not creating a new one. See pinpoint-testing skill § "Where Existing Coverage Lives".
- Class-J self-check (AGENTS.md §2.1 "Test What We Own"): Does this spec hit any URL outside
localhost, 127.0.0.1, or our owned local stack (Mailpit, PGlite, local Supabase)? If yes → STOP. Mock the SDK at the boundary in src/lib/<service>/client.test.ts. Live Discord, real OAuth provider redirects, vendor email-template parsing are violations.
- Would the assertion be the same if I called the Server Action directly with PGlite seeded data? If yes → integration test, not E2E. The browser overhead buys nothing.
- Is this a genuine multi-step user journey that spans two or more page renders (e.g. login → mutate → verify across pages)? If no → almost certainly not E2E.
If all five say "E2E is the right layer", write it. Otherwise, the cheapest layer that catches the bug class wins.
Quick Start
- Run Smoke Tests:
pnpm run smoke (Fast, critical paths)
- Run Full Suite:
pnpm run e2e:full (Comprehensive — CI only, don't run locally unless asked)
- Debug Mode:
pnpm exec playwright test e2e/path/to/test.spec.ts --debug
Which Tests to Run (Decision Tree)
- Changed pure logic/utils? →
pnpm run check (unit tests, ~12s)
- Changed a single E2E-relevant file? →
pnpm exec playwright test e2e/path/to/file.spec.ts --project=chromium (~15-30s)
- Changed UI components/forms? →
pnpm run smoke (~60s)
- Changed auth/permissions/middleware? →
pnpm run smoke + targeted full specs
- Changed DB schema/migrations? →
pnpm run preflight (full suite)
- NEVER run
e2e:full locally unless explicitly asked — that's what CI is for
Key rules for agents:
- Always use
--project=chromium for targeted runs (skip Mobile Chrome unless testing responsive)
- Use
--headed for debugging visual issues
pnpm run check catches 90% of issues — E2E is for integration verification, not iteration
- If a test is flaky locally, report it — don't retry in a loop
The Golden Rule: Worker Isolation
PinPoint E2E tests run in parallel against a shared database.
YOU MUST PREVENT CROSSTALK.
- Unique Data: Never assume the DB is empty. Always create your own unique data.
- Unique Users: Do not share
admin@test.com across parallel tests if those tests modify global state (e.g., settings, notifications).
- Unique Machines: Create a fresh machine for your test.
- Unique Titles: Use
getTestIssueTitle("My Title") to prefix issues with [w0_xyz].
Common Helpers
- Select Reset Assertions: Use
assertSelectAtPlaceholder(trigger, placeholderText) for placeholder state, or assertSelectValue(trigger, expectedLabel) for default value state (e.g. await assertSelectValue(page.getByTestId("select-id"), "Minor")).
References
Debugging Checklist
If a test fails in CI or parallel mode:
- Crosstalk?: Is it seeing data from another worker? (Check screenshots for other prefixes).
- Fix: Use
getTestPrefix() filtering and unique resources.
- Session Lost?: Redirecting to
/report/success or /login unexpectedly?
- Fix: Ensure
x-skip-autologin is NOT interfering. Add test.use({ storageState: STORAGE_STATE.<role> }) to the describe block, or use loginAs for mid-test role switches. Check test.describe.serial if tests share a user.
- Timeout?: Waiting for a toast or email?
- Fix: Use
waitForLoadState("networkidle") before assertions. Increase timeouts for emails.
- Mobile layout different?: Nav links not visible on mobile?
- Fix: AppHeader is unified — same
data-testid="app-header" on all viewports. Nav links hide below md:, BottomTabBar handles mobile navigation. Use testInfo.project.name.includes("Mobile") only when testing layout-specific behavior (e.g., checking BottomTabBar visibility).
Authentication Strategy
Decision tree for new tests:
| Test type | Auth approach |
|---|
| Tests one role throughout | test.use({ storageState: STORAGE_STATE.<role> }) |
| Switches roles mid-test | loginAs(page, testInfo, { email, password }) |
| Tests login/signup/password reset | No auth — start unauthenticated |
| Tests public routes | No auth — omit test.use() |
Dynamic user (created via createTestUser) | loginAs after creating the user |
Available roles:
import { STORAGE_STATE } from "../support/auth-state";
No auth needed for unauthenticated tests — simply omit test.use().
Creating a New Test
-
Scaffold (single-role — preferred):
import { test, expect } from "@playwright/test";
import { STORAGE_STATE } from "../support/auth-state";
import { getTestIssueTitle } from "../support/test-isolation";
test.describe("My Feature", () => {
test.use({ storageState: STORAGE_STATE.member });
test("my feature works", async ({ page }) => {
const title = getTestIssueTitle("Feature Test");
await page.goto("/dashboard");
});
});
-
Scaffold (multi-role or auth flow — use loginAs):
import { test, expect } from "@playwright/test";
import { loginAs } from "../support/actions";
test("role-switch works", async ({ page }, testInfo) => {
await loginAs(page, testInfo);
});
-
Isolate: If modifying global state, create a temp user/machine in beforeAll.
-
Cleanup: Delete created resources in afterAll.