From 6bd5eb663bf60d591e380692e239d04681a6a790 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Mon, 12 Jan 2026 21:43:24 +0000 Subject: [PATCH] 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 --- .gitignore | 4 ++ .mcp.json | 8 +++ e2e/smoke.spec.ts | 36 ++++++++++++ flake.lock | 68 +++++++++++++++++++++- flake.nix | 30 ++++++---- package.json | 4 ++ playwright.config.ts | 46 +++++++++++++++ pnpm-lock.yaml | 43 +++++++++++++- spec.md | 2 +- specs/testing.md | 130 ++++++++++++++++++++++++++++++++++++++----- 10 files changed, 344 insertions(+), 27 deletions(-) create mode 100644 .mcp.json create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts diff --git a/.gitignore b/.gitignore index 89a1af8..fcd99c4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ # testing /coverage +# playwright +/playwright-report/ +/test-results/ + # next.js /.next/ /out/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..259a959 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..a69c795 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -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(); + } + }); +}); diff --git a/flake.lock b/flake.lock index 42c9926..b9b9d8b 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "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": { "locked": { "lastModified": 1767892417, @@ -16,9 +34,57 @@ "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": { "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" } } }, diff --git a/flake.nix b/flake.nix index cb0d570..067c114 100644 --- a/flake.nix +++ b/flake.nix @@ -2,11 +2,13 @@ # ABOUTME: Provides Node.js 24, pnpm, turbo, lefthook, and Docker image output. { 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 system = "x86_64-linux"; pkgs = nixpkgs.legacyPackages.${system}; + playwright-driver = playwright-web-flake.packages.${system}.playwright-driver; # Custom Python package: garth (not in nixpkgs) garth = pkgs.python3Packages.buildPythonPackage { @@ -48,32 +50,40 @@ devShells.${system} = { # Default development shell with all tools default = pkgs.mkShell { - packages = commonPackages ++ (with pkgs; [ - turbo - lefthook - ]); + packages = commonPackages ++ [ + pkgs.turbo + pkgs.lefthook + playwright-driver + ]; # For native modules (sharp, better-sqlite3, etc.) 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 # Used for autonomous Ralph loop execution ralph = pkgs.mkShell { - packages = commonPackages ++ (with pkgs; [ - # Claude CLI (assumes installed globally or via npm) - # Add any other tools Ralph needs here - ]); + packages = commonPackages ++ [ + playwright-driver + ]; # Restrictive environment for sandboxed execution shellHook = '' echo "🔒 Ralph Sandbox Environment" - echo " Limited to: nodejs, pnpm, git" + echo " Limited to: nodejs, pnpm, git, playwright" echo "" ''; # For native modules 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"; }; }; }; diff --git a/package.json b/package.json index 01e68fe..65b06e7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "lint:fix": "biome check --write .", "test": "vitest", "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" }, "dependencies": { @@ -31,6 +34,7 @@ }, "devDependencies": { "@biomejs/biome": "2.3.11", + "@playwright/test": "1.56.1", "@tailwindcss/postcss": "^4", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..77bf5da --- /dev/null +++ b/playwright.config.ts @@ -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 + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ed298b..c0fa705 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 0.562.0(react@19.2.3) next: 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: specifier: ^4.2.1 version: 4.2.1 @@ -57,6 +57,9 @@ importers: '@biomejs/biome': specifier: 2.3.11 version: 2.3.11 + '@playwright/test': + specifier: 1.56.1 + version: 1.56.1 '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 @@ -974,6 +977,11 @@ packages: '@pinojs/redact@0.4.0': 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': resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} @@ -1553,6 +1561,11 @@ packages: picomatch: 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: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1778,6 +1791,16 @@ packages: resolution: {integrity: sha512-3qqVfpJtRQUCAOs4rTOEwLH6mwJJ/CSAlbis8fKOiMzTtXh0HN/VLsn3UWVTJ7U8DsWmxeNon2IpGb+wORXH4g==} 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: resolution: {integrity: sha512-SXcq+sRvVpNxfLxPB1C+8eRatL7ZY4o3EVl/0OdE3MeR9fhPyZt0nmmxLqYmkLvXCN9qp3lXWV/0EUYb3MmMXQ==} @@ -2723,6 +2746,10 @@ snapshots: '@pinojs/redact@0.4.0': {} + '@playwright/test@1.56.1': + dependencies: + playwright: 1.56.1 + '@rolldown/pluginutils@1.0.0-beta.53': {} '@rollup/rollup-android-arm-eabi@4.55.1': @@ -3213,6 +3240,9 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -3363,7 +3393,7 @@ snapshots: 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: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 @@ -3383,6 +3413,7 @@ snapshots: '@next/swc-win32-arm64-msvc': 16.1.1 '@next/swc-win32-x64-msvc': 16.1.1 '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.56.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -3426,6 +3457,14 @@ snapshots: sonic-boom: 4.2.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: {} postcss@8.4.31: diff --git a/spec.md b/spec.md index bd74fd4..9e519c6 100644 --- a/spec.md +++ b/spec.md @@ -835,7 +835,7 @@ The following are **out of scope** for MVP: | Hormonal birth control | May disrupt natural cycle phases | | API versioning | Single version; breaking changes via deprecation | | Formal API documentation | Endpoints documented in spec only | -| E2E tests | Unit + integration tests only (authorized skip) | +| Multi-user support | Single-user design only | --- diff --git a/specs/testing.md b/specs/testing.md index 7bb9aee..0caa518 100644 --- a/specs/testing.md +++ b/specs/testing.md @@ -6,7 +6,7 @@ When I make changes to the codebase, I want automated tests to catch regressions ## 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 @@ -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` | | Integration | API routes, PocketBase interactions | Vitest + supertest | Colocated `*.test.ts` | +| E2E | Full user flows, browser interactions | Playwright | `e2e/*.spec.ts` | ## 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 -Tests colocated with source files: +Tests colocated with source files (unit/integration) or in `e2e/` directory (E2E): ``` src/ @@ -164,22 +252,33 @@ src/ today/ route.ts route.test.ts +e2e/ + smoke.spec.ts + dashboard.spec.ts + auth.spec.ts + settings.spec.ts ``` ## Running Tests ```bash -# Run all tests -npm test - -# Run with coverage -npm run test:coverage +# Run unit/integration tests +pnpm test:run # Run in watch mode -npm run test:watch +pnpm test -# Run specific file -npm test -- src/lib/cycle.test.ts +# Run E2E tests (headless) +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 @@ -190,15 +289,20 @@ No strict coverage thresholds for MVP, but aim for: ## Success Criteria -1. All tests pass in CI before merge -2. Core decision engine logic has comprehensive tests +1. All tests (unit, integration, E2E) pass in CI before merge +2. Core decision engine logic has comprehensive unit tests 3. Phase scaling tested for multiple cycle lengths 4. API auth tested for protected routes +5. Critical user flows covered by E2E 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 cycle phase calculations - [ ] 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