Add Playwright E2E testing infrastructure

- Add playwright-web-flake to flake.nix for NixOS browser support
- Pin @playwright/test@1.56.1 to match nixpkgs version
- Create playwright.config.ts with Chromium-only, auto-start dev server
- Add e2e/smoke.spec.ts with initial smoke tests
- Add .mcp.json for Claude browser control via MCP
- Update .gitignore for playwright artifacts
- Remove E2E test skip from spec.md Known Limitations
- Update specs/testing.md to require three-tier testing approach

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 21:43:24 +00:00
parent 30c5955a61
commit 6bd5eb663b
10 changed files with 344 additions and 27 deletions

4
.gitignore vendored
View File

@@ -13,6 +13,10 @@
# testing # testing
/coverage /coverage
# playwright
/playwright-report/
/test-results/
# next.js # next.js
/.next/ /.next/
/out/ /out/

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}

36
e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,36 @@
// ABOUTME: Smoke tests to verify basic application functionality.
// ABOUTME: Tests that the app loads and critical pages are accessible.
import { expect, test } from "@playwright/test";
test.describe("smoke tests", () => {
test("app loads with correct title", async ({ page }) => {
await page.goto("/");
// Verify the app loads by checking the page title
await expect(page).toHaveTitle("PhaseFlow");
});
test("login page is accessible", async ({ page }) => {
await page.goto("/login");
// Verify login page loads
await expect(page).toHaveURL(/\/login/);
});
test("unauthenticated root redirects or shows login option", async ({
page,
}) => {
await page.goto("/");
// The app should either redirect to login or show a login link
// Check for either condition
const url = page.url();
const hasLoginInUrl = url.includes("/login");
const loginLink = page.getByRole("link", { name: /login|sign in/i });
// At least one should be true: either we're on login page or there's a login link
if (!hasLoginInUrl) {
await expect(loginLink).toBeVisible();
}
});
});

68
flake.lock generated
View File

@@ -1,5 +1,23 @@
{ {
"nodes": { "nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1726560853,
"narHash": "sha256-X6rJYSESBVr3hBoH0WbKE5KvhPU5bloyZ2L4K60/fPQ=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c1dfcf08411b08f6b8615f7d8971a2bfa81d5e8a",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1767892417, "lastModified": 1767892417,
@@ -16,9 +34,57 @@
"type": "github" "type": "github"
} }
}, },
"nixpkgs_2": {
"locked": {
"lastModified": 0,
"narHash": "sha256-u+rxA79a0lyhG+u+oPBRtTDtzz8kvkc9a6SWSt9ekVc=",
"path": "/nix/store/0283cbhm47kd3lr9zmc5fvdrx9qkav8s-source",
"type": "path"
},
"original": {
"id": "nixpkgs",
"type": "indirect"
}
},
"playwright-web-flake": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1764622772,
"narHash": "sha256-WCvvlB9sH6u8MQUkFnlxx7jDh7kIebTDK/JHi6pPqSA=",
"owner": "pietdevries94",
"repo": "playwright-web-flake",
"rev": "88e0e6c69b9086619b0c4d8713b2bfaf81a21c40",
"type": "github"
},
"original": {
"owner": "pietdevries94",
"ref": "1.56.1",
"repo": "playwright-web-flake",
"type": "github"
}
},
"root": { "root": {
"inputs": { "inputs": {
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs",
"playwright-web-flake": "playwright-web-flake"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
} }
} }
}, },

View File

@@ -2,11 +2,13 @@
# ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output. # ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output.
{ {
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
outputs = { nixpkgs, ... }: outputs = { nixpkgs, playwright-web-flake, ... }:
let let
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
playwright-driver = playwright-web-flake.packages.${system}.playwright-driver;
# Custom Python package: garth (not in nixpkgs) # Custom Python package: garth (not in nixpkgs)
garth = pkgs.python3Packages.buildPythonPackage { garth = pkgs.python3Packages.buildPythonPackage {
@@ -48,32 +50,40 @@
devShells.${system} = { devShells.${system} = {
# Default development shell with all tools # Default development shell with all tools
default = pkgs.mkShell { default = pkgs.mkShell {
packages = commonPackages ++ (with pkgs; [ packages = commonPackages ++ [
turbo pkgs.turbo
lefthook pkgs.lefthook
]); playwright-driver
];
# For native modules (sharp, better-sqlite3, etc.) # For native modules (sharp, better-sqlite3, etc.)
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ]; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
# Playwright browser configuration for NixOS (from playwright-web-flake)
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
}; };
# Ralph sandbox shell with minimal permissions # Ralph sandbox shell with minimal permissions
# Used for autonomous Ralph loop execution # Used for autonomous Ralph loop execution
ralph = pkgs.mkShell { ralph = pkgs.mkShell {
packages = commonPackages ++ (with pkgs; [ packages = commonPackages ++ [
# Claude CLI (assumes installed globally or via npm) playwright-driver
# Add any other tools Ralph needs here ];
]);
# Restrictive environment for sandboxed execution # Restrictive environment for sandboxed execution
shellHook = '' shellHook = ''
echo "🔒 Ralph Sandbox Environment" echo "🔒 Ralph Sandbox Environment"
echo " Limited to: nodejs, pnpm, git" echo " Limited to: nodejs, pnpm, git, playwright"
echo "" echo ""
''; '';
# For native modules # For native modules
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ]; LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc ];
# Playwright browser configuration for NixOS (from playwright-web-flake)
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
}; };
}; };
}; };

View File

@@ -10,6 +10,9 @@
"lint:fix": "biome check --write .", "lint:fix": "biome check --write .",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"db:setup": "npx tsx scripts/setup-db.ts" "db:setup": "npx tsx scripts/setup-db.ts"
}, },
"dependencies": { "dependencies": {
@@ -31,6 +34,7 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.3.11", "@biomejs/biome": "2.3.11",
"@playwright/test": "1.56.1",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1", "@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",

46
playwright.config.ts Normal file
View File

@@ -0,0 +1,46 @@
// ABOUTME: Playwright E2E test configuration for browser-based testing.
// ABOUTME: Configures Chromium-only headless testing with automatic dev server startup.
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
// Test directory for E2E tests
testDir: "./e2e",
// Run tests in parallel
fullyParallel: true,
// Fail the build on CI if you accidentally left test.only in the source code
forbidOnly: !!process.env.CI,
// Retry failed tests on CI only
retries: process.env.CI ? 2 : 0,
// Limit parallel workers on CI to avoid resource issues
workers: process.env.CI ? 1 : undefined,
// Reporter configuration
reporter: [["html", { open: "never" }], ["list"]],
// Shared settings for all projects
use: {
// Base URL for navigation actions like page.goto('/')
baseURL: "http://localhost:3000",
// Collect trace on first retry for debugging
trace: "on-first-retry",
// Take screenshot on failure
screenshot: "only-on-failure",
},
// Configure projects - Chromium only per requirements
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
// Run dev server before starting tests
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, // 2 minutes for Next.js to start
},
});

43
pnpm-lock.yaml generated
View File

@@ -25,7 +25,7 @@ importers:
version: 0.562.0(react@19.2.3) version: 0.562.0(react@19.2.3)
next: next:
specifier: 16.1.1 specifier: 16.1.1
version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
node-cron: node-cron:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1 version: 4.2.1
@@ -57,6 +57,9 @@ importers:
'@biomejs/biome': '@biomejs/biome':
specifier: 2.3.11 specifier: 2.3.11
version: 2.3.11 version: 2.3.11
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
version: 4.1.18 version: 4.1.18
@@ -974,6 +977,11 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@playwright/test@1.56.1':
resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/pluginutils@1.0.0-beta.53': '@rolldown/pluginutils@1.0.0-beta.53':
resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==}
@@ -1553,6 +1561,11 @@ packages:
picomatch: picomatch:
optional: true optional: true
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: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1778,6 +1791,16 @@ packages:
resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==} resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==}
hasBin: true hasBin: true
playwright-core@1.56.1:
resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==}
engines: {node: '>=18'}
hasBin: true
playwright@1.56.1:
resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==}
engines: {node: '>=18'}
hasBin: true
pocketbase@0.26.5: pocketbase@0.26.5:
resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==} resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==}
@@ -2723,6 +2746,10 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@playwright/test@1.56.1':
dependencies:
playwright: 1.56.1
'@rolldown/pluginutils@1.0.0-beta.53': {} '@rolldown/pluginutils@1.0.0-beta.53': {}
'@rollup/rollup-android-arm-eabi@4.55.1': '@rollup/rollup-android-arm-eabi@4.55.1':
@@ -3213,6 +3240,9 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
@@ -3363,7 +3393,7 @@ snapshots:
nanoid@3.3.11: {} nanoid@3.3.11: {}
next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
'@next/env': 16.1.1 '@next/env': 16.1.1
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
@@ -3383,6 +3413,7 @@ snapshots:
'@next/swc-win32-arm64-msvc': 16.1.1 '@next/swc-win32-arm64-msvc': 16.1.1
'@next/swc-win32-x64-msvc': 16.1.1 '@next/swc-win32-x64-msvc': 16.1.1
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@playwright/test': 1.56.1
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -3426,6 +3457,14 @@ snapshots:
sonic-boom: 4.2.0 sonic-boom: 4.2.0
thread-stream: 4.0.0 thread-stream: 4.0.0
playwright-core@1.56.1: {}
playwright@1.56.1:
dependencies:
playwright-core: 1.56.1
optionalDependencies:
fsevents: 2.3.2
pocketbase@0.26.5: {} pocketbase@0.26.5: {}
postcss@8.4.31: postcss@8.4.31:

View File

@@ -835,7 +835,7 @@ The following are **out of scope** for MVP:
| Hormonal birth control | May disrupt natural cycle phases | | Hormonal birth control | May disrupt natural cycle phases |
| API versioning | Single version; breaking changes via deprecation | | API versioning | Single version; breaking changes via deprecation |
| Formal API documentation | Endpoints documented in spec only | | Formal API documentation | Endpoints documented in spec only |
| E2E tests | Unit + integration tests only (authorized skip) | | Multi-user support | Single-user design only |
--- ---

View File

@@ -6,7 +6,7 @@ When I make changes to the codebase, I want automated tests to catch regressions
## Testing Strategy ## Testing Strategy
PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are not required for MVP (authorized skip). PhaseFlow uses a **three-tier testing approach**: unit tests, integration tests, and end-to-end tests.
### Test Types ### Test Types
@@ -14,6 +14,7 @@ PhaseFlow uses **unit and integration tests** with Vitest. End-to-end tests are
|------|-------|-------|----------| |------|-------|-------|----------|
| Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` | | Unit | Pure functions, utilities | Vitest | Colocated `*.test.ts` |
| Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` | | Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` |
| E2E | Full user flows, browser interactions | Playwright | `e2e/*.spec.ts` |
## Framework ## Framework
@@ -148,9 +149,96 @@ describe('GET /api/today', () => {
}); });
``` ```
## End-to-End Tests
Test complete user flows through the browser using Playwright.
### Framework
**Playwright** - Cross-browser E2E testing with auto-waiting and tracing.
**Configuration (`playwright.config.ts`):**
```typescript
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { open: "never" }], ["list"]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});
```
### Priority Targets
| Flow | Tests |
|------|-------|
| Authentication | Login page loads, login redirects, logout works |
| Dashboard | Shows decision, displays cycle info, override toggles work |
| Settings | Garmin token paste, preferences save |
| Calendar | ICS feed accessible, calendar view renders |
| Period logging | "Period Started" updates cycle |
### Example Test
```typescript
// e2e/dashboard.spec.ts
import { expect, test } from "@playwright/test";
test.describe("dashboard", () => {
test("shows training decision for authenticated user", async ({ page }) => {
// Login first (or use auth state)
await page.goto("/");
// Verify decision card is visible
await expect(page.getByTestId("decision-card")).toBeVisible();
// Verify cycle day is displayed
await expect(page.getByText(/Day \d+ of \d+/)).toBeVisible();
});
test("override toggles update decision", async ({ page }) => {
await page.goto("/");
// Enable flare mode
await page.getByLabel("Flare Mode").click();
// Decision should change to gentle
await expect(page.getByText(/GENTLE/)).toBeVisible();
});
});
```
### NixOS Setup
E2E tests require browser binaries. On NixOS, use `playwright-web-flake`:
```nix
# In flake.nix inputs
inputs.playwright-web-flake.url = "github:pietdevries94/playwright-web-flake/1.56.1";
# In devShell
PLAYWRIGHT_BROWSERS_PATH = "${playwright-driver.browsers}";
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
```
## File Naming ## File Naming
Tests colocated with source files: Tests colocated with source files (unit/integration) or in `e2e/` directory (E2E):
``` ```
src/ src/
@@ -164,22 +252,33 @@ src/
today/ today/
route.ts route.ts
route.test.ts route.test.ts
e2e/
smoke.spec.ts
dashboard.spec.ts
auth.spec.ts
settings.spec.ts
``` ```
## Running Tests ## Running Tests
```bash ```bash
# Run all tests # Run unit/integration tests
npm test pnpm test:run
# Run with coverage
npm run test:coverage
# Run in watch mode # Run in watch mode
npm run test:watch pnpm test
# Run specific file # Run E2E tests (headless)
npm test -- src/lib/cycle.test.ts pnpm test:e2e
# Run E2E tests (visible browser)
pnpm test:e2e:headed
# Run E2E tests with UI mode
pnpm test:e2e:ui
# Run all tests (unit + E2E)
pnpm test:run && pnpm test:e2e
``` ```
## Coverage Expectations ## Coverage Expectations
@@ -190,15 +289,20 @@ No strict coverage thresholds for MVP, but aim for:
## Success Criteria ## Success Criteria
1. All tests pass in CI before merge 1. All tests (unit, integration, E2E) pass in CI before merge
2. Core decision engine logic has comprehensive tests 2. Core decision engine logic has comprehensive unit tests
3. Phase scaling tested for multiple cycle lengths 3. Phase scaling tested for multiple cycle lengths
4. API auth tested for protected routes 4. API auth tested for protected routes
5. Critical user flows covered by E2E tests
## Acceptance Tests ## Acceptance Tests
- [ ] `npm test` runs without errors - [ ] `pnpm test:run` runs without errors
- [ ] `pnpm test:e2e` runs without errors
- [ ] Unit tests cover decision engine logic - [ ] Unit tests cover decision engine logic
- [ ] Unit tests cover cycle phase calculations - [ ] Unit tests cover cycle phase calculations
- [ ] Integration tests verify API authentication - [ ] Integration tests verify API authentication
- [ ] E2E tests verify login flow
- [ ] E2E tests verify dashboard displays correctly
- [ ] E2E tests verify period logging works
- [ ] Tests run in CI pipeline - [ ] Tests run in CI pipeline