Add Playwright E2E test setup (#3169)

* docs: design e2e test setup

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* docs: plan e2e test implementation

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add playwright e2e scripts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: configure playwright

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: add core e2e fixtures and specs

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* ci: run e2e tests on pull requests

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* [autofix.ci] apply automated fixes

* test: stabilize e2e suite

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* test: ignore playwright artifacts

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

* Update .github/workflows/e2e.yml

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* test: address e2e review feedback

Co-authored-by: Amruth Pillai <im.amruth@gmail.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Amruth Pillai
2026-06-20 07:39:06 +02:00
committed by GitHub
parent 56c90947e4
commit dfd2c77bc9
18 changed files with 1474 additions and 33 deletions
+88
View File
@@ -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
+2
View File
@@ -36,6 +36,8 @@ logs
# Testing
coverage
reports
playwright-report
test-results
# Cache
tmp
@@ -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<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.
@@ -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=<non-empty test secret>`
- `FLAG_DISABLE_SIGNUPS=false`
- `FLAG_DISABLE_EMAIL_AUTH=false`
- `FLAG_DISABLE_API_RATE_LIMIT=true`
- `LOCAL_STORAGE_PATH=<absolute temp 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-<runId>-<worker>-<suffix>@example.test`
- Username: `e2e_<runId>_<worker>_<suffix>`
- 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.
+6
View File
@@ -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"
+44
View File
@@ -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),
},
},
});
+78 -33
View File
@@ -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
+37
View File
@@ -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.
+66
View File
@@ -0,0 +1,66 @@
import type { APIRequestContext, Browser, BrowserContext, Page } from "@playwright/test";
import type { E2EAccount } from "./data";
async function assertAuthResponse(response: Awaited<ReturnType<APIRequestContext["post"]>>) {
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<BrowserContext> {
await registerViaApi(request, account, baseURL);
return browser.newContext({
baseURL,
storageState: await request.storageState(),
});
}
+37
View File
@@ -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)}`;
}
+19
View File
@@ -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();
}
}
+29
View File
@@ -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();
}
+46
View File
@@ -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<Fixtures>({
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 };
+13
View File
@@ -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();
});
@@ -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);
});
+21
View File
@@ -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();
}
});
+23
View File
@@ -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);
});
+3
View File
@@ -101,6 +101,9 @@
"test": {},
"test:coverage": {},
"test:ci": {},
"test:e2e": {
"cache": false
},
"test:agent": {},
"dev": {
"cache": false,