From f6709d2badda607512e87a23f7512e9b8cc8c8e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 20 Jun 2026 04:04:43 +0000 Subject: [PATCH] docs: plan e2e test implementation Co-authored-by: Amruth Pillai --- .../superpowers/plans/2026-06-20-e2e-tests.md | 649 ++++++++++++++++++ 1 file changed, 649 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-e2e-tests.md diff --git a/docs/superpowers/plans/2026-06-20-e2e-tests.md b/docs/superpowers/plans/2026-06-20-e2e-tests.md new file mode 100644 index 000000000..2e94454fb --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-e2e-tests.md @@ -0,0 +1,649 @@ +# 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 --reporter=list,github,junit" +} +``` + +- [ ] **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 = Boolean(process.env.CI); + +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` + +Use a local test environment: + +`APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres AUTH_SECRET=e2e-test-secret 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 AUTH_SECRET=e2e-test-secret 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 + AUTH_SECRET: e2e-test-secret + 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:latest + 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 + + - 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: Prepare Storage + run: mkdir -p "$LOCAL_STORAGE_PATH" + + - 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 +APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres AUTH_SECRET=e2e-test-secret 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 AUTH_SECRET=e2e-test-secret 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.