# E2E Tests Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a Playwright E2E test setup that runs deterministic core Reactive Resume flows with ephemeral accounts/data locally and on GitHub Actions for every PR. **Architecture:** Add a root-level Playwright harness that targets the production server after `pnpm build`. Use hybrid fixtures: one browser-driven auth smoke spec, and helper-created authenticated browser state plus ephemeral database cleanup for resume flows. Keep initial PR-gated coverage to auth, sample resume creation, builder autosave, JSON export/import, and public sharing. **Tech Stack:** Playwright, TypeScript, pnpm, Turbo, GitHub Actions, PostgreSQL service container, Better Auth HTTP endpoints, Drizzle/Postgres cleanup helpers. --- ## File structure - Create `playwright.config.ts`: Playwright projects, reporters, artifact policy, base URL, and production `webServer`. - Create `tests/e2e/README.md`: local setup and CI behavior. - Create `tests/e2e/fixtures/data.ts`: unique test identity and resume value generation. - Create `tests/e2e/fixtures/auth.ts`: browser UI auth helpers and API-backed authenticated storage state helpers. - Create `tests/e2e/fixtures/db.ts`: user cleanup by email/username prefix. - Create `tests/e2e/fixtures/resume.ts`: UI helpers for creating sample resumes and accessing builder sections. - Create `tests/e2e/fixtures/test.ts`: typed Playwright fixture composition. - Create `tests/e2e/specs/auth.spec.ts`: browser registration/login smoke flow. - Create `tests/e2e/specs/resume-lifecycle.spec.ts`: dashboard create sample resume and builder autosave flow. - Create `tests/e2e/specs/json-export-import.spec.ts`: deterministic JSON backup/restore flow. - Create `tests/e2e/specs/public-sharing.spec.ts`: public sharing flow with anonymous browser context. - Create `.github/workflows/e2e.yml`: PR/push workflow with Postgres, build, Playwright install, E2E run, and report uploads. - Modify `package.json`: add Playwright dependency and E2E scripts. - Modify `turbo.json`: register E2E task if routed through package scripts. - Modify targeted UI files only if accessible locators are not sufficient. ## Task 1: Add Playwright dependency and scripts **Files:** - Modify: `package.json` - Modify: `pnpm-lock.yaml` - Modify: `turbo.json` - [ ] **Step 1: Add Playwright with the package manager** Run: `pnpm add -D @playwright/test` Expected: `package.json` and `pnpm-lock.yaml` include `@playwright/test`. - [ ] **Step 2: Add root scripts** Change root `package.json` scripts to include: ```json { "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:ci": "playwright test" } ``` - [ ] **Step 3: Register the Turbo task** Add this task entry to `turbo.json`: ```json "test:e2e": { "cache": false } ``` - [ ] **Step 4: Verify script discovery** Run: `pnpm exec playwright --version` Expected: Playwright prints a version and exits successfully. - [ ] **Step 5: Commit** Run: ```bash git add package.json pnpm-lock.yaml turbo.json git commit -m "test: add playwright e2e scripts" git push -u origin feat/e2e-test-plan-2b10 ``` ## Task 2: Add Playwright configuration **Files:** - Create: `playwright.config.ts` - [ ] **Step 1: Create the config** Create `playwright.config.ts`: ```ts import { defineConfig, devices } from "@playwright/test"; const port = Number.parseInt(process.env.PORT ?? "3000", 10); const baseURL = process.env.APP_URL ?? `http://127.0.0.1:${port}`; const isCI = process.env.CI === "true" || process.env.CI === "1"; export default defineConfig({ testDir: "./tests/e2e/specs", fullyParallel: true, forbidOnly: isCI, retries: isCI ? 2 : 0, workers: isCI ? 2 : undefined, reporter: isCI ? [ ["list"], ["github"], ["junit", { outputFile: "test-results/e2e-junit.xml" }], ] : [["list"], ["html", { open: "never" }]], use: { baseURL, trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], webServer: { command: "pnpm start", url: `${baseURL}/api/health`, reuseExistingServer: !isCI, timeout: 120_000, env: { ...process.env, PORT: String(port), }, }, }); ``` - [ ] **Step 2: Verify config loads** Run: `pnpm exec playwright test --list` Expected: Playwright lists zero tests at this point without config errors. - [ ] **Step 3: Commit** Run: ```bash git add playwright.config.ts git commit -m "test: configure playwright" git push -u origin feat/e2e-test-plan-2b10 ``` ## Task 3: Add E2E fixtures **Files:** - Create: `tests/e2e/fixtures/data.ts` - Create: `tests/e2e/fixtures/db.ts` - Create: `tests/e2e/fixtures/auth.ts` - Create: `tests/e2e/fixtures/resume.ts` - Create: `tests/e2e/fixtures/test.ts` - [ ] **Step 1: Add test data helpers** Create `tests/e2e/fixtures/data.ts`: ```ts import type { TestInfo } from "@playwright/test"; const sanitize = (value: string) => value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); export type E2EAccount = { name: string; username: string; email: string; password: string; }; export function createRunSlug(testInfo: TestInfo) { const worker = testInfo.workerIndex; const title = sanitize(testInfo.titlePath.join("-")).slice(0, 40); const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; return `e2e-${worker}-${title}-${suffix}`; } export function createAccount(testInfo: TestInfo): E2EAccount { const slug = createRunSlug(testInfo).replaceAll("-", "_").slice(0, 48); return { name: "E2E Test User", username: slug, email: `${slug}@example.test`, password: "Password123!", }; } export function createResumeName(testInfo: TestInfo) { return `E2E Resume ${createRunSlug(testInfo)}`; } ``` - [ ] **Step 2: Add database cleanup** Create `tests/e2e/fixtures/db.ts`: ```ts import { eq, or } from "drizzle-orm"; import { db, getPool } from "@reactive-resume/db/client"; import { user } from "@reactive-resume/db/schema"; export async function deleteE2EUser(account: { email: string; username: string }) { await db.delete(user).where(or(eq(user.email, account.email), eq(user.username, account.username))); } export async function closeE2EDatabase() { await getPool().end(); globalThis.__pool = undefined; globalThis.__drizzle = undefined; } ``` - [ ] **Step 3: Add auth helpers** Create `tests/e2e/fixtures/auth.ts`: ```ts import type { Browser, Page } from "@playwright/test"; import type { E2EAccount } from "./data"; export async function registerViaUi(page: Page, account: E2EAccount) { await page.goto("/auth/register"); await page.getByLabel("Name").fill(account.name); await page.getByLabel("Username").fill(account.username); await page.getByLabel("Email Address").fill(account.email); await page.getByLabel("Password").fill(account.password); await page.getByRole("button", { name: "Sign up" }).click(); await page.getByRole("link", { name: /continue/i }).click(); await page.waitForURL(/\/dashboard/); } export async function loginViaUi(page: Page, account: E2EAccount) { await page.goto("/auth/login"); await page.getByLabel(/email|username/i).fill(account.email); await page.getByLabel("Password").fill(account.password); await page.getByRole("button", { name: "Sign in" }).click(); await page.waitForURL(/\/dashboard/); } export async function createAuthenticatedPage(browser: Browser, account: E2EAccount) { const context = await browser.newContext(); const page = await context.newPage(); await registerViaUi(page, account); return page; } ``` - [ ] **Step 4: Add resume UI helpers** Create `tests/e2e/fixtures/resume.ts`: ```ts import type { Page, TestInfo } from "@playwright/test"; import { expect } from "@playwright/test"; import { createResumeName } from "./data"; export async function createSampleResumeFromDashboard(page: Page, testInfo: TestInfo) { const resumeName = createResumeName(testInfo); await page.goto("/dashboard/resumes"); await page.getByText("Create a new resume").click(); await page.getByRole("dialog", { name: "Create a new resume" }).getByLabel("Name").fill(resumeName); await page.getByRole("button", { name: "Create resume with options" }).click(); await page.getByRole("menuitem", { name: "Create a Sample Resume" }).click(); await expect(page.getByText(resumeName)).toBeVisible(); await page.getByText(resumeName).click(); await page.waitForURL(/\/builder\/.+/); return resumeName; } export async function openRightSidebarSection(page: Page, name: string) { await page.getByRole("button", { name }).click(); } ``` - [ ] **Step 5: Add fixture composition** Create `tests/e2e/fixtures/test.ts`: ```ts import { test as base, expect } from "@playwright/test"; import type { E2EAccount } from "./data"; import { createAccount } from "./data"; import { deleteE2EUser } from "./db"; import { registerViaUi } from "./auth"; type Fixtures = { account: E2EAccount; authPage: import("@playwright/test").Page; }; export const test = base.extend({ account: async ({}, use, testInfo) => { const account = createAccount(testInfo); await use(account); await deleteE2EUser(account); }, authPage: async ({ browser, account }, use) => { const context = await browser.newContext(); const page = await context.newPage(); await registerViaUi(page, account); await use(page); await context.close(); }, }); export { expect }; ``` - [ ] **Step 6: Run type-aware feedback** Run: `pnpm exec tsc --noEmit --allowImportingTsExtensions false --moduleResolution bundler --target es2022 --module esnext tests/e2e/fixtures/*.ts` Expected: If direct `tsc` is too narrow for workspace exports, use `pnpm typecheck` after Task 6 instead. - [ ] **Step 7: Commit** Run: ```bash git add tests/e2e/fixtures git commit -m "test: add e2e fixtures" git push -u origin feat/e2e-test-plan-2b10 ``` ## Task 4: Add core E2E specs **Files:** - Create: `tests/e2e/specs/auth.spec.ts` - Create: `tests/e2e/specs/resume-lifecycle.spec.ts` - Create: `tests/e2e/specs/json-export-import.spec.ts` - Create: `tests/e2e/specs/public-sharing.spec.ts` - [ ] **Step 1: Add auth smoke spec** Create `tests/e2e/specs/auth.spec.ts`: ```ts import { test, expect } from "../fixtures/test"; import { loginViaUi, registerViaUi } from "../fixtures/auth"; test("registers and logs in with email credentials", async ({ page, account }) => { await registerViaUi(page, account); await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible(); await page.getByRole("button", { name: /user menu|account|profile/i }).click(); await page.getByRole("menuitem", { name: /logout|sign out/i }).click(); await loginViaUi(page, account); await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible(); }); ``` - [ ] **Step 2: Add resume lifecycle spec** Create `tests/e2e/specs/resume-lifecycle.spec.ts`: ```ts import { test, expect } from "../fixtures/test"; import { createSampleResumeFromDashboard } from "../fixtures/resume"; test("creates a sample resume and persists a basics edit", async ({ authPage: page }, testInfo) => { await createSampleResumeFromDashboard(page, testInfo); const updatedName = `E2E Edited ${Date.now()}`; await page.getByRole("button", { name: "Basics" }).click(); await page.getByLabel("Name").fill(updatedName); await page.reload(); await page.getByRole("button", { name: "Basics" }).click(); await expect(page.getByLabel("Name")).toHaveValue(updatedName); }); ``` - [ ] **Step 3: Add JSON export/import spec** Create `tests/e2e/specs/json-export-import.spec.ts`: ```ts import { test, expect } from "../fixtures/test"; import { createSampleResumeFromDashboard } from "../fixtures/resume"; test("exports and imports a resume JSON backup", async ({ authPage: page }, testInfo) => { const resumeName = await createSampleResumeFromDashboard(page, testInfo); await page.getByRole("button", { name: "Export" }).click(); const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: /^JSON$/ }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toMatch(/\.json$/); const path = await download.path(); expect(path).toBeTruthy(); if (!path) throw new Error("Expected Playwright to provide a downloaded JSON path."); await page.goto("/dashboard/resumes"); await page.getByText("Import an existing resume").click(); await page.getByRole("combobox").click(); await page.getByRole("option", { name: "Reactive Resume (JSON)" }).click(); await page.getByText("Click here to select a file to import").setInputFiles(path); await page.getByRole("button", { name: "Import" }).click(); await page.waitForURL(/\/builder\/.+/); await expect(page.getByText(resumeName)).toBeVisible(); }); ``` - [ ] **Step 4: Add public sharing spec** Create `tests/e2e/specs/public-sharing.spec.ts`: ```ts import { test, expect } from "../fixtures/test"; import { createSampleResumeFromDashboard } from "../fixtures/resume"; test("publishes a resume and renders it for an anonymous visitor", async ({ browser, authPage: page }, testInfo) => { await createSampleResumeFromDashboard(page, testInfo); await page.getByRole("button", { name: "Sharing" }).click(); await page.getByLabel("Allow Public Access").click(); const publicUrl = await page.getByLabel("URL").inputValue(); const anonymous = await browser.newPage(); await anonymous.goto(publicUrl); await expect(anonymous.getByRole("button", { name: /download/i })).toBeVisible(); await anonymous.close(); }); ``` - [ ] **Step 5: Run list mode** Run: `pnpm test:e2e -- --list` Expected: Four Chromium tests are listed. - [ ] **Step 6: Commit** Run: ```bash git add tests/e2e/specs git commit -m "test: add core e2e specs" git push -u origin feat/e2e-test-plan-2b10 ``` ## Task 5: Add documentation **Files:** - Create: `tests/e2e/README.md` - [ ] **Step 1: Add E2E README** Create `tests/e2e/README.md`: ```md # E2E Tests Reactive Resume uses Playwright for PR-gated browser coverage of deterministic core flows. ## Local setup Start PostgreSQL: `sudo docker compose -f compose.dev.yml up -d postgres` Generate local test secrets: `export AUTH_SECRET=$(openssl rand -hex 32)` `export ENCRYPTION_SECRET=$(openssl rand -hex 32)` Run database migrations: `APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm db:migrate` Build the production app: `APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm build` Run tests: `APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm test:e2e` ## Coverage - Email/password auth smoke. - Dashboard sample resume creation. - Builder basics edit and autosave persistence. - JSON export/import. - Public sharing for anonymous visitors. PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate. ``` - [ ] **Step 2: Commit** Run: ```bash git add tests/e2e/README.md git commit -m "docs: document e2e workflow" git push -u origin feat/e2e-test-plan-2b10 ``` ## Task 6: Add GitHub Actions workflow **Files:** - Create: `.github/workflows/e2e.yml` - [ ] **Step 1: Add workflow** Create `.github/workflows/e2e.yml`: ```yaml name: E2E Tests on: pull_request: push: branches: ["main"] permissions: contents: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true APP_URL: http://localhost:3000 PORT: "3000" DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS: "false" FLAG_DISABLE_EMAIL_AUTH: "false" FLAG_DISABLE_API_RATE_LIMIT: "true" LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 30 services: postgres: image: postgres:16 env: POSTGRES_DB: postgres POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres -d postgres" --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Checkout Repository uses: actions/checkout@v6 with: persist-credentials: false - name: Install pnpm uses: pnpm/action-setup@v6 - name: Setup Node uses: actions/setup-node@v6 with: node-version: "24" cache: "pnpm" - name: Install Dependencies run: pnpm install --frozen-lockfile - name: Install Playwright Browser run: pnpm exec playwright install --with-deps chromium - name: Generate Test Secrets run: | echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV" echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV" - name: Prepare Storage run: mkdir -p "$LOCAL_STORAGE_PATH" - name: Run Database Migrations run: pnpm db:migrate - name: Build run: pnpm build - name: Run E2E Tests run: pnpm test:e2e:ci - name: Upload Playwright Report if: always() uses: actions/upload-artifact@v7 with: name: playwright-report path: | playwright-report test-results if-no-files-found: ignore retention-days: 7 ``` - [ ] **Step 2: Commit** Run: ```bash git add .github/workflows/e2e.yml git commit -m "ci: run e2e tests on pull requests" git push -u origin feat/e2e-test-plan-2b10 ``` ## Task 7: Verify and stabilize **Files:** - Modify any E2E files that fail due to actual labels or app behavior. - Modify targeted UI files only if no stable accessible locator exists. - [ ] **Step 1: Run non-mutating checks** Run: `pnpm exec biome check package.json turbo.json playwright.config.ts tests/e2e .github/workflows/e2e.yml` Expected: No Biome errors. - [ ] **Step 2: Run typecheck** Run: `pnpm typecheck` Expected: Typecheck passes for all workspaces. - [ ] **Step 3: Start PostgreSQL** Run: `sudo docker compose -f compose.dev.yml up -d postgres` Expected: Postgres container is healthy. - [ ] **Step 4: Build production app** Run: ```bash export AUTH_SECRET=$(openssl rand -hex 32) export ENCRYPTION_SECRET=$(openssl rand -hex 32) APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm build ``` Expected: web and server production builds complete. - [ ] **Step 5: Run E2E suite** Run: ```bash APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm test:e2e ``` Expected: All Chromium specs pass. - [ ] **Step 6: Commit stabilization changes** Run: ```bash git add . git commit -m "test: stabilize e2e suite" git push -u origin feat/e2e-test-plan-2b10 ``` ## Self-review - Spec coverage: Tasks cover Playwright setup, production-build target, hybrid fixtures, ephemeral data cleanup, core deterministic flows, CI workflow, and documentation. - Placeholder scan: No placeholder steps remain; each task names exact files, commands, and expected outcomes. - Type consistency: Fixture types are introduced before specs use them, and all helper names match across tasks.