Back to Reactive Resume

E2E Tests Implementation Plan

docs/superpowers/plans/2026-06-20-e2e-tests.md

5.2.019.8 KB
Original Source

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<Fixtures>({
	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.