diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..962e30bc6 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,88 @@ +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 diff --git a/.gitignore b/.gitignore index 6390b17f8..022205870 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ logs # Testing coverage reports +playwright-report +test-results # Cache tmp 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..d5cb80dc9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-e2e-tests.md @@ -0,0 +1,670 @@ +# 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. diff --git a/docs/superpowers/specs/2026-06-19-e2e-tests-design.md b/docs/superpowers/specs/2026-06-19-e2e-tests-design.md new file mode 100644 index 000000000..11fc70616 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-e2e-tests-design.md @@ -0,0 +1,262 @@ +# E2E Tests Design + +## Context + +Reactive Resume is a pnpm/Turborepo monorepo with two deployable apps: + +- `apps/web`: TanStack Start / React / Vite user interface. +- `apps/server`: Hono / Node.js server that owns API, auth, MCP, static uploads, OpenAPI, and production web serving. + +The product is a resume builder. The highest-value browser journeys are: + +1. Create an account and authenticate. +2. Create a resume from the dashboard. +3. Open the builder and edit resume basics. +4. Persist builder edits through autosave. +5. Export and import structured resume JSON. +6. Enable public sharing and view the resume as an anonymous visitor. + +The repository currently has broad Vitest coverage but no Playwright or Cypress harness. GitHub Actions currently runs autofix/lint behavior on PRs and Docker image publishing on pushes/tags, but no PR-gated E2E workflow. + +Context7 was requested for current framework guidance, but the configured Context7 quota was exhausted in this environment. The implementation plan should re-check current Playwright docs when Context7 access is available. + +## Goals + +- Add a working Playwright E2E setup. +- Run E2E tests against the production build path, not only Vite dev mode. +- Use ephemeral accounts and data for each run. +- Cover deterministic core UX flows on every PR. +- Keep CI runtime and flake risk low enough for a default PR gate. +- Leave room to add PDF, DOCX, OAuth, passkey, 2FA, and AI flows later without bloating the initial gate. + +## Non-goals + +- Do not add PDF or DOCX download validation to the initial PR gate. +- Do not automate OAuth, passkeys, 2FA, or password reset in the first suite. +- Do not include AI Agent, AI import, or resume analysis flows in the first suite. +- Do not add a cross-browser matrix initially. +- Do not assert pixel-perfect PDF/canvas rendering. + +## Recommended approach + +Use a root-level Playwright harness that runs Chromium against a production build: + +1. CI installs dependencies and Playwright Chromium. +2. CI starts PostgreSQL. +3. CI sets test environment variables. +4. CI builds the app with `pnpm build`. +5. Playwright starts the production server with `pnpm start`. +6. E2E specs exercise browser flows against `APP_URL`. + +Use hybrid fixtures: + +- The auth smoke test registers and logs in through the browser UI. +- Non-auth specs create ephemeral users and authenticated browser state through test helpers. +- Resume setup can use helper APIs or direct database helpers when the setup itself is not the behavior under test. +- Each test uses unique data and cleans up its own ephemeral user. + +This keeps the suite close to user reality while avoiding repeated slow setup in every test. + +## File layout + +Add: + +- `playwright.config.ts` +- `tests/e2e/README.md` +- `tests/e2e/fixtures/test.ts` +- `tests/e2e/fixtures/auth.ts` +- `tests/e2e/fixtures/db.ts` +- `tests/e2e/fixtures/resume.ts` +- `tests/e2e/specs/auth.spec.ts` +- `tests/e2e/specs/resume-lifecycle.spec.ts` +- `tests/e2e/specs/json-export-import.spec.ts` +- `tests/e2e/specs/public-sharing.spec.ts` +- `.github/workflows/e2e.yml` + +Update: + +- root `package.json` scripts +- `turbo.json` tasks, if root scripts are routed through Turbo +- lockfile after adding Playwright +- contributing docs if a short root README is not enough + +## Playwright configuration + +The initial config should: + +- Use Chromium only. +- Read `baseURL` from `APP_URL`, defaulting to `http://localhost:3000`. +- Use traces, screenshots, and videos on failure. +- Use a local-friendly reporter plus a CI reporter/artifact format. +- Start `pnpm start` through `webServer`. +- Reuse an existing local server outside CI when possible. +- Prefer role, label, and accessible-name locators. +- Add targeted `data-testid` attributes only when accessibility locators are not stable enough. + +Recommended root scripts: + +- `test:e2e`: run Playwright. +- `test:e2e:ui`: run Playwright UI mode. +- `test:e2e:ci`: run Playwright with CI reporter settings. + +## Environment and infrastructure + +Minimum E2E environment: + +- `APP_URL=http://localhost:3000` +- `PORT=3000` +- `DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres` +- `AUTH_SECRET=` +- `FLAG_DISABLE_SIGNUPS=false` +- `FLAG_DISABLE_EMAIL_AUTH=false` +- `FLAG_DISABLE_API_RATE_LIMIT=true` +- `LOCAL_STORAGE_PATH=` + +S3, Redis, SMTP, and AI provider variables are not required for the initial deterministic suite. + +PostgreSQL should be isolated for CI runs. The simplest PR workflow can use a GitHub Actions Postgres service container and a clean database per job. Local development can use `compose.dev.yml` to start Postgres. + +## Ephemeral data strategy + +Each worker/test should generate unique identities: + +- Email: `e2e---@example.test` +- Username: `e2e___` +- Resume names/slugs prefixed with `e2e-` + +Cleanup should delete the ephemeral user and rely on database cascade relationships for related resumes, sessions, accounts, API keys, and related records. + +Use helper setup where it improves speed and stability: + +- Create authenticated storage state for non-auth specs. +- Create seeded resumes for tests that are about builder/export/sharing behavior rather than resume creation. +- Keep one browser-driven auth spec to ensure registration/login still works end to end. + +## Initial PR-gated specs + +### Auth smoke + +Flow: + +1. Visit `/auth/register`. +2. Register a unique user through the UI. +3. Continue to `/dashboard`. +4. Log out. +5. Log in with the same credentials. +6. Assert the dashboard resumes page loads. + +Purpose: + +- Proves email/password auth, session cookies, redirects, and dashboard access work. + +### Resume lifecycle + +Flow: + +1. Start authenticated as an ephemeral user. +2. Visit `/dashboard/resumes`. +3. Create a sample resume through the UI. +4. Open the resume in the builder. +5. Edit the basics name field. +6. Wait for autosave. +7. Reload the builder. +8. Assert the edited value persisted. + +Purpose: + +- Proves dashboard create, builder load, form editing, API update, and autosave persistence. + +### JSON export and import + +Flow: + +1. Start authenticated with a sample resume. +2. Open the builder. +3. Export JSON. +4. Assert a JSON download is created and parseable. +5. Return to dashboard. +6. Import the downloaded JSON through the import dialog. +7. Assert the imported resume opens in the builder. + +Purpose: + +- Proves deterministic backup/restore without AI or binary rendering dependencies. + +### Public sharing + +Flow: + +1. Start authenticated with a sample resume. +2. Open builder sharing settings. +3. Enable public access. +4. Read the generated public URL. +5. Open a fresh unauthenticated browser context. +6. Visit the public URL. +7. Assert the public resume viewer renders expected resume content. + +Purpose: + +- Proves sharing state updates, public routing, anonymous access, and public resume rendering. + +## CI workflow + +Add `.github/workflows/e2e.yml`: + +- Trigger on `pull_request` and pushes to `main`. +- Use Node 24. +- Use pnpm via Corepack or `pnpm/action-setup`. +- Cache pnpm dependencies through `actions/setup-node`. +- Start PostgreSQL service. +- Install dependencies with `pnpm install --frozen-lockfile`. +- Install Playwright Chromium. +- Run `pnpm build`. +- Run `pnpm test:e2e:ci`. +- Upload Playwright reports, traces, screenshots, and videos on failure. + +The workflow should avoid secrets. All test credentials should be generated during the run. + +## Selector strategy + +Prefer stable user-facing locators: + +- Roles: buttons, links, dialogs, textboxes, comboboxes. +- Labels: form inputs and switches. +- URLs: route assertions after navigation. + +Add `data-testid` only where necessary: + +- Builder canvas/PDF viewer surfaces that lack accessible names. +- Download/import controls if file input access is otherwise too implementation-dependent. +- Toast-independent save-state assertions if no visible stable status exists. + +Keep test IDs narrow and product-neutral. + +## Reliability considerations + +- Autosave currently debounces changes by 500ms. Tests should wait for persisted state through reload or API-visible state, not arbitrary sleeps. +- Public resume viewer uses browser PDF/canvas behavior. Assert meaningful content or viewer presence, not pixels. +- JSON export/import is deterministic and should be the first export/import loop. +- Disable API rate limiting in test env to avoid worker-order flakes. +- Pin locale to the default English path or use role/label selectors robust to translations where practical. +- Keep the initial suite serial only where shared worker state forces it; otherwise allow Playwright worker isolation. + +## Future expansion + +After the initial suite is stable: + +1. Add PDF download validation as a separate optional or nightly job. +2. Add DOCX download validation if the PDF job proves stable. +3. Add password-protected public sharing. +4. Add dashboard management flows: duplicate, lock, delete, tag filtering, list/grid view. +5. Add settings flows: profile update, API key creation/deletion. +6. Add AI flows only with mocked providers or a deterministic local provider. +7. Add browser matrix only after the Chromium gate has low flake rates. + +## Success criteria + +- `pnpm test:e2e` runs locally against a built app and Postgres. +- `pnpm test:e2e:ci` runs in GitHub Actions on every PR. +- Tests create no persistent shared seed data. +- Tests clean up ephemeral users and related data. +- The initial suite covers auth, dashboard creation, builder autosave, JSON export/import, and public sharing. +- CI uploads useful artifacts for diagnosing failures. diff --git a/package.json b/package.json index 139085aaf..7a2721f7d 100644 --- a/package.json +++ b/package.json @@ -35,23 +35,29 @@ "test": "turbo run test", "test:coverage": "turbo run test:coverage", "test:ci": "turbo run test:ci", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:ci": "playwright test", "test:agent": "turbo run test:agent" }, "devDependencies": { "@biomejs/biome": "^2.5.0", "@commitlint/cli": "^21.0.2", "@commitlint/config-conventional": "^21.0.2", + "@playwright/test": "^1.61.0", "@reactive-resume/config": "workspace:*", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@types/node": "^25.9.3", + "@types/pg": "^8.20.0", "@vitest/coverage-v8": "^4.1.9", "happy-dom": "^20.10.5", "knip": "^6.17.1", "lefthook": "^2.1.9", "npm-check-updates": "^22.2.3", + "pg": "^8.21.0", "turbo": "^2.9.18", "typescript": "^6.0.3", "vitest": "^4.1.9" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..1850e5709 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,44 @@ +import { defineConfig, devices } from "@playwright/test"; + +const port = Number.parseInt(process.env.PORT ?? "3000", 10); +const baseURL = process.env.APP_URL ?? `http://localhost:${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, + timeout: 60_000, + expect: { + timeout: 10_000, + }, + 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, + APP_URL: baseURL, + NODE_ENV: "production", + PORT: String(port), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c4e6ab2c..5a7f66f17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@commitlint/config-conventional': specifier: ^21.0.2 version: 21.0.2 + '@playwright/test': + specifier: ^1.61.0 + version: 1.61.0 '@reactive-resume/config': specifier: workspace:* version: link:packages/config @@ -45,6 +48,9 @@ importers: '@types/node': specifier: ^25.9.3 version: 25.9.3 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@vitest/coverage-v8': specifier: ^4.1.9 version: 4.1.9(vitest@4.1.9) @@ -60,6 +66,9 @@ importers: npm-check-updates: specifier: ^22.2.3 version: 22.2.3 + pg: + specifier: ^8.21.0 + version: 8.21.0 turbo: specifier: ^2.9.18 version: 2.9.18 @@ -89,19 +98,19 @@ importers: version: 3.1070.0 '@better-auth/api-key': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) '@better-auth/drizzle-adapter': specifier: ^1.6.19 version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3)) '@better-auth/infra': specifier: ^0.2.14 - version: 0.2.14(5043c63593801db60a42278b5a1e566b) + version: 0.2.14(fca79a91fb956ec11f36b6e601c4bb43) '@better-auth/oauth-provider': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) '@better-auth/passkey': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0) '@hono/node-server': specifier: ^2.0.5 version: 2.0.5(hono@4.12.25) @@ -167,7 +176,7 @@ importers: version: 6.0.0 better-auth: specifier: 1.6.19 - version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) cjk-regex: specifier: ^3.4.0 version: 3.4.0 @@ -279,16 +288,16 @@ importers: version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@better-auth/api-key': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) '@better-auth/infra': specifier: ^0.2.14 - version: 0.2.14(5043c63593801db60a42278b5a1e566b) + version: 0.2.14(fca79a91fb956ec11f36b6e601c4bb43) '@better-auth/oauth-provider': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) '@better-auth/passkey': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -402,7 +411,7 @@ importers: version: 6.0.207(zod@4.4.3) better-auth: specifier: 1.6.19 - version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -614,7 +623,7 @@ importers: version: 6.0.0 better-auth: specifier: 1.6.19 - version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3) @@ -663,19 +672,19 @@ importers: dependencies: '@better-auth/api-key': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) '@better-auth/drizzle-adapter': specifier: ^1.6.19 version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3)) '@better-auth/infra': specifier: ^0.2.14 - version: 0.2.14(5043c63593801db60a42278b5a1e566b) + version: 0.2.14(fca79a91fb956ec11f36b6e601c4bb43) '@better-auth/oauth-provider': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) '@better-auth/passkey': specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0) + version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0) '@reactive-resume/db': specifier: workspace:* version: link:../db @@ -693,7 +702,7 @@ importers: version: 6.0.0 better-auth: specifier: 1.6.19 - version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3) @@ -798,7 +807,7 @@ importers: devDependencies: '@react-email/ui': specifier: ^6.6.3 - version: 6.6.3(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + version: 6.6.3(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@reactive-resume/config': specifier: workspace:* version: link:../config @@ -3090,6 +3099,11 @@ packages: '@phosphor-icons/web@2.1.2': resolution: {integrity: sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==} + '@playwright/test@1.61.0': + resolution: {integrity: sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==} + engines: {node: '>=18'} + hasBin: true + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -5416,6 +5430,11 @@ packages: resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -6617,6 +6636,16 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + playwright-core@1.61.0: + resolution: {integrity: sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.0: + resolution: {integrity: sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==} + engines: {node: '>=18'} + hasBin: true + png-js@2.0.0: resolution: {integrity: sha512-GdzJuUMc6ZSpxFJWVxtOH1bzYHym+TOnveqUjb+VJIbZWbZzyiRGFiKhbiielfpYbgMlhHVhsJ0FTazfuRFkMA==} @@ -8430,11 +8459,11 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@better-auth/api-key@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))': + '@better-auth/api-key@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))': dependencies: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-call: 1.3.6(zod@4.4.3) zod: 4.4.3 @@ -8459,13 +8488,13 @@ snapshots: optionalDependencies: drizzle-orm: 1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3) - '@better-auth/infra@0.2.14(5043c63593801db60a42278b5a1e566b)': + '@better-auth/infra@0.2.14(fca79a91fb956ec11f36b6e601c4bb43)': dependencies: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) - '@better-auth/sso': 1.6.11(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) + '@better-auth/sso': 1.6.11(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3)) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-call: 1.3.6(zod@4.4.3) jose: 6.2.3 libphonenumber-js: 1.13.6 @@ -8488,24 +8517,24 @@ snapshots: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/oauth-provider@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))': + '@better-auth/oauth-provider@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))': dependencies: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-call: 1.3.6(zod@4.4.3) jose: 6.2.3 zod: 4.4.3 - '@better-auth/passkey@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0)': + '@better-auth/passkey@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0)': dependencies: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@simplewebauthn/browser': 13.3.0 '@simplewebauthn/server': 13.3.1 - better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-call: 1.3.6(zod@4.4.3) nanostores: 1.3.0 zod: 4.4.3 @@ -8515,12 +8544,12 @@ snapshots: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/sso@1.6.11(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))': + '@better-auth/sso@1.6.11(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9))(better-call@1.3.6(zod@4.4.3))': dependencies: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) + better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9) better-call: 1.3.6(zod@4.4.3) fast-xml-parser: 5.9.2 jose: 6.2.3 @@ -9849,6 +9878,10 @@ snapshots: '@phosphor-icons/web@2.1.2': {} + '@playwright/test@1.61.0': + dependencies: + playwright: 1.61.0 + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -10002,10 +10035,10 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - '@react-email/ui@6.6.3(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + '@react-email/ui@6.6.3(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: esbuild: 0.28.1 - next: 16.2.6(@babel/core@8.0.0)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.6(@babel/core@8.0.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) transitivePeerDependencies: - '@babel/core' - '@opentelemetry/api' @@ -11201,7 +11234,7 @@ snapshots: node-addon-api: 8.8.0 node-gyp-build: 4.8.4 - better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9): + better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3))(next@16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9): dependencies: '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3)) @@ -11222,7 +11255,7 @@ snapshots: zod: 4.4.3 optionalDependencies: drizzle-orm: 1.0.0-rc.3(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(pg@8.21.0)(zod@4.4.3) - next: 16.2.6(@babel/core@8.0.0)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + next: 16.2.6(@babel/core@8.0.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) pg: 8.21.0 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) @@ -12015,6 +12048,9 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -12929,7 +12965,7 @@ snapshots: react: 19.2.7 react-dom: 19.2.7(react@19.2.7) - next@16.2.6(@babel/core@8.0.0)(@opentelemetry/api@1.9.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + next@16.2.6(@babel/core@8.0.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.61.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7): dependencies: '@next/env': 16.2.6 '@swc/helpers': 0.5.15 @@ -12949,6 +12985,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.2.6 '@next/swc-win32-x64-msvc': 16.2.6 '@opentelemetry/api': 1.9.1 + '@playwright/test': 1.61.0 babel-plugin-react-compiler: 1.0.0 sharp: 0.34.5 transitivePeerDependencies: @@ -13269,6 +13306,14 @@ snapshots: dependencies: find-up: 3.0.0 + playwright-core@1.61.0: {} + + playwright@1.61.0: + dependencies: + playwright-core: 1.61.0 + optionalDependencies: + fsevents: 2.3.2 + png-js@2.0.0: dependencies: fflate: 0.8.3 diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 000000000..1db085291 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,37 @@ +# 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. diff --git a/tests/e2e/fixtures/auth.ts b/tests/e2e/fixtures/auth.ts new file mode 100644 index 000000000..4d4d13f20 --- /dev/null +++ b/tests/e2e/fixtures/auth.ts @@ -0,0 +1,66 @@ +import type { APIRequestContext, Browser, BrowserContext, Page } from "@playwright/test"; +import type { E2EAccount } from "./data"; + +async function assertAuthResponse(response: Awaited>) { + if (response.ok()) return; + + throw new Error(`Authentication request failed with ${response.status()}: ${await response.text()}`); +} + +export async function registerViaUi(page: Page, account: E2EAccount) { + await page.goto("/auth/register"); + await page.getByRole("textbox", { name: "Name", exact: true }).fill(account.name); + await page.getByLabel("Username").fill(account.username); + await page.getByLabel("Email Address", { exact: true }).fill(account.email); + await page.getByLabel("Password", { exact: true }).fill(account.password); + await page.getByRole("button", { name: "Sign up" }).click(); + await page.getByRole("button", { name: "Continue" }).click(); + await page.waitForURL(/\/dashboard/); +} + +export async function loginViaUi(page: Page, account: E2EAccount) { + await page.goto("/auth/login"); + await page.getByLabel("Email Address", { exact: true }).fill(account.email); + await page.getByLabel("Password", { exact: true }).fill(account.password); + await page.getByRole("button", { name: "Sign in" }).click(); + await page.waitForURL(/\/dashboard/); +} + +export async function logoutViaUi(page: Page, account: E2EAccount) { + await page.getByText(account.email).click(); + await page.getByRole("menuitem", { name: "Logout" }).click(); + await page.goto("/auth/login"); +} + +async function registerViaApi(request: APIRequestContext, account: E2EAccount, baseURL: string) { + const response = await request.post("/api/auth/sign-up/email", { + headers: { + origin: baseURL, + referer: `${baseURL}/auth/register`, + }, + data: { + name: account.name, + email: account.email, + password: account.password, + username: account.username, + displayUsername: account.username, + callbackURL: "/dashboard", + }, + }); + + await assertAuthResponse(response); +} + +export async function createAuthenticatedContext( + browser: Browser, + request: APIRequestContext, + account: E2EAccount, + baseURL: string, +): Promise { + await registerViaApi(request, account, baseURL); + + return browser.newContext({ + baseURL, + storageState: await request.storageState(), + }); +} diff --git a/tests/e2e/fixtures/data.ts b/tests/e2e/fixtures/data.ts new file mode 100644 index 000000000..3aac145ab --- /dev/null +++ b/tests/e2e/fixtures/data.ts @@ -0,0 +1,37 @@ +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; +}; + +function createRunSlug(testInfo: TestInfo) { + const worker = testInfo.workerIndex; + const title = sanitize(testInfo.titlePath.join("-")).slice(0, 32); + 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 username = createRunSlug(testInfo).replaceAll("-", "_").slice(0, 64); + + return { + name: "E2E Test User", + username, + email: `${username}@example.test`, + password: "Password123!", + }; +} + +export function createResumeName(testInfo: TestInfo) { + return `E2E Resume ${createRunSlug(testInfo)}`; +} diff --git a/tests/e2e/fixtures/db.ts b/tests/e2e/fixtures/db.ts new file mode 100644 index 000000000..d221be760 --- /dev/null +++ b/tests/e2e/fixtures/db.ts @@ -0,0 +1,19 @@ +import type { E2EAccount } from "./data"; +import { Pool } from "pg"; + +function getDatabaseUrl() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error("DATABASE_URL is required for E2E cleanup."); + + return databaseUrl; +} + +export async function deleteE2EUser(account: E2EAccount) { + const pool = new Pool({ connectionString: getDatabaseUrl() }); + + try { + await pool.query('delete from "user" where email = $1 or username = $2', [account.email, account.username]); + } finally { + await pool.end(); + } +} diff --git a/tests/e2e/fixtures/resume.ts b/tests/e2e/fixtures/resume.ts new file mode 100644 index 000000000..f3a2af568 --- /dev/null +++ b/tests/e2e/fixtures/resume.ts @@ -0,0 +1,29 @@ +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(); + + const dialog = page.getByRole("dialog", { name: "Create a new resume" }); + await dialog.getByLabel("Name").fill(resumeName); + + const createGroup = dialog.getByRole("group", { name: "Create resume with options" }); + await createGroup.getByRole("button").last().click(); + await page.getByRole("menuitem", { name: "Create a Sample Resume" }).click(); + + const resumeLink = page.getByRole("link", { name: new RegExp(resumeName) }); + await expect(resumeLink).toBeVisible(); + await resumeLink.click(); + await page.waitForURL(/\/builder\/.+/); + + return resumeName; +} + +export async function openSidebarSection(page: Page, title: string) { + await page.getByTitle(title, { exact: true }).click(); + await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible(); +} diff --git a/tests/e2e/fixtures/test.ts b/tests/e2e/fixtures/test.ts new file mode 100644 index 000000000..85891132e --- /dev/null +++ b/tests/e2e/fixtures/test.ts @@ -0,0 +1,46 @@ +import type { BrowserContext, Page } from "@playwright/test"; +import type { E2EAccount } from "./data"; +import { test as base, expect } from "@playwright/test"; +import { createAuthenticatedContext } from "./auth"; +import { createAccount } from "./data"; +import { deleteE2EUser } from "./db"; + +type Fixtures = { + account: E2EAccount; + authContext: BrowserContext; + authPage: Page; +}; + +export const test = base.extend({ + account: async ({ baseURL }, use, testInfo) => { + void baseURL; + const account = createAccount(testInfo); + + try { + await use(account); + } finally { + await deleteE2EUser(account); + } + }, + authContext: async ({ browser, request, account }, use, testInfo) => { + const baseURL = String(testInfo.project.use.baseURL ?? "http://localhost:3000"); + const context = await createAuthenticatedContext(browser, request, account, baseURL); + + try { + await use(context); + } finally { + await context.close(); + } + }, + authPage: async ({ authContext }, use) => { + const page = await authContext.newPage(); + + try { + await use(page); + } finally { + await page.close(); + } + }, +}); + +export { expect }; diff --git a/tests/e2e/specs/auth.spec.ts b/tests/e2e/specs/auth.spec.ts new file mode 100644 index 000000000..65ea08953 --- /dev/null +++ b/tests/e2e/specs/auth.spec.ts @@ -0,0 +1,13 @@ +import { loginViaUi, logoutViaUi, registerViaUi } from "../fixtures/auth"; +import { expect, test } from "../fixtures/test"; + +test("registers and logs in with email credentials", async ({ page, account }) => { + await registerViaUi(page, account); + await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible(); + + await logoutViaUi(page, account); + await expect(page.getByRole("heading", { name: "Sign in to your account" })).toBeVisible(); + + await loginViaUi(page, account); + await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible(); +}); diff --git a/tests/e2e/specs/json-export-import.spec.ts b/tests/e2e/specs/json-export-import.spec.ts new file mode 100644 index 000000000..ddc9be9d6 --- /dev/null +++ b/tests/e2e/specs/json-export-import.spec.ts @@ -0,0 +1,30 @@ +import { readFile } from "node:fs/promises"; +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +test("exports and imports a resume JSON backup", async ({ authPage: page }, testInfo) => { + await createSampleResumeFromDashboard(page, testInfo); + + await openSidebarSection(page, "Export"); + + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: /^JSON/ }).click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toMatch(/\.json$/); + + const downloadPath = testInfo.outputPath(download.suggestedFilename()); + await download.saveAs(downloadPath); + const exportedData = JSON.parse(await readFile(downloadPath, "utf-8")) as { basics: { name: string } }; + + await page.goto("/dashboard/resumes"); + await page.getByText("Import an existing resume").click(); + const dialog = page.getByRole("dialog", { name: "Import an existing resume" }); + await dialog.getByRole("combobox").click(); + await page.getByRole("option", { name: "Reactive Resume (JSON)" }).click(); + await dialog.locator('input[type="file"]').setInputFiles(downloadPath); + await dialog.getByRole("button", { name: "Import", exact: true }).click(); + + await page.waitForURL(/\/builder\/.+/); + await openSidebarSection(page, "Basics"); + await expect(page.getByLabel("Name")).toHaveValue(exportedData.basics.name); +}); diff --git a/tests/e2e/specs/public-sharing.spec.ts b/tests/e2e/specs/public-sharing.spec.ts new file mode 100644 index 000000000..92a7c6976 --- /dev/null +++ b/tests/e2e/specs/public-sharing.spec.ts @@ -0,0 +1,21 @@ +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +test("publishes a resume and renders it for an anonymous visitor", async ({ browser, authPage: page }, testInfo) => { + await createSampleResumeFromDashboard(page, testInfo); + await openSidebarSection(page, "Sharing"); + + await page.getByRole("switch", { name: /Allow Public Access/ }).click(); + const sharingUrl = page.locator("#sharing-url"); + await expect(sharingUrl).toHaveValue(/\/e2e_/); + const publicUrl = await sharingUrl.inputValue(); + expect(publicUrl).toMatch(/\/e2e_/); + + const anonymous = await browser.newPage(); + try { + await anonymous.goto(publicUrl); + await expect(anonymous.getByRole("button", { name: "Download PDF" })).toBeVisible(); + } finally { + await anonymous.close(); + } +}); diff --git a/tests/e2e/specs/resume-lifecycle.spec.ts b/tests/e2e/specs/resume-lifecycle.spec.ts new file mode 100644 index 000000000..03c6944ac --- /dev/null +++ b/tests/e2e/specs/resume-lifecycle.spec.ts @@ -0,0 +1,23 @@ +import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; +import { expect, test } from "../fixtures/test"; + +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 openSidebarSection(page, "Basics"); + const savePromise = page.waitForResponse((response) => { + if (!response.url().includes("/api/rpc")) return false; + if (response.request().method() !== "POST") return false; + if (!response.ok()) return false; + + const body = response.request().postData() ?? ""; + return body.includes(updatedName); + }); + await page.getByLabel("Name").fill(updatedName); + await savePromise; + + await page.reload(); + await openSidebarSection(page, "Basics"); + await expect(page.getByLabel("Name")).toHaveValue(updatedName); +}); diff --git a/turbo.json b/turbo.json index 9596aa0f9..4031b1c81 100644 --- a/turbo.json +++ b/turbo.json @@ -101,6 +101,9 @@ "test": {}, "test:coverage": {}, "test:ci": {}, + "test:e2e": { + "cache": false + }, "test:agent": {}, "dev": { "cache": false,