import { defineConfig, devices } from "@playwright/test"; import * as path from "node:path"; import { PERSONAS } from "./fixtures/personas"; /** * Playwright end-to-end suite for the HR and Finance modules. * * Scope and doctrine are set by docs/hr-finance-ui-e2e-matrix.md (Phase 0, * approved 2026-08-24): every scenario asserts the DOM, the network response * AND the database row, and fails if the three disagree. * * ── Isolation ──────────────────────────────────────────────────────────────── * Runs against `smart_office_e2e` on its OWN ports, so a run can never disturb * the dev stack a human is using, nor write into `smart_office_prod` (a * production replica). Both are load-bearing: * * | service | dev | e2e | * | hr-api | 3005 | 3105 | * | finance-api | 3004 | 3104 | * | hr-web | 5185 | 5285 | * | finance-web | 5186 | 5286 | * * `DB_NAME`, `PORT` and `CORS_ORIGINS` all override the app's own `.env` * (verified), and Vite reads `PORT`/`VITE_*` from the process environment — so * no config file is edited to run the suite. * * CORS_ORIGINS is not optional: without it the API rejects the browser at the * preflight and login fails with a bare "Network Error" that names nothing. */ const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104"; const HR_WEB = process.env.HR_WEB_URL ?? "http://localhost:5285"; const FINANCE_WEB = process.env.FINANCE_WEB_URL ?? "http://localhost:5286"; const DB_NAME = process.env.DB_NAME ?? "smart_office_e2e"; const STORAGE = path.join(__dirname, "fixtures", "storage"); const webEnv = { GITHUB_PACKAGE_TOKEN: process.env.GITHUB_PACKAGE_TOKEN ?? "dummy", }; export default defineConfig({ testDir: path.join(__dirname, "specs"), // Shared seeded database, and several scenarios move state that cannot be // moved back (a posted journal, an approved payroll run). Serialize, as the // passenger suite does, so assertions stay deterministic. fullyParallel: false, workers: 1, retries: 0, timeout: 60_000, expect: { timeout: 10_000 }, globalSetup: path.join(__dirname, "global-setup.ts"), reporter: [ ["list"], [ "html", { outputFolder: path.join(__dirname, "..", "e2e-hr-finance-report"), open: "never", }, ], ], webServer: [ { command: "pnpm --filter @edr/hr-api start", url: `${HR_API}/api-docs`, timeout: 180_000, reuseExistingServer: true, env: { ...webEnv, PORT: "3105", DB_NAME, CORS_ORIGINS: HR_WEB }, }, { command: "pnpm --filter @edr/finance-api start", url: `${FINANCE_API}/api-docs`, timeout: 180_000, reuseExistingServer: true, env: { ...webEnv, PORT: "3104", DB_NAME, CORS_ORIGINS: FINANCE_WEB }, }, { command: "pnpm --filter @edr/hr-web dev", url: HR_WEB, timeout: 120_000, reuseExistingServer: true, env: { ...webEnv, PORT: "5285", VITE_HR_API_URL: HR_API, // hr-api serves POST /api/v1/auth/login itself (it embeds IamModule). // The app's own .env points login at passenger-api :4000 on the belief // that it does not — that belief is wrong, and the suite does not // inherit the coupling. VITE_AUTH_API_URL: HR_API, VITE_AUTH_BASE_PATH: "/api/v1", VITE_CLIENT_APP: "", }, }, { command: "pnpm --filter @edr/finance-web dev", url: FINANCE_WEB, timeout: 120_000, reuseExistingServer: true, env: { ...webEnv, PORT: "5286", VITE_FINANCE_API_URL: FINANCE_API, VITE_AUTH_API_URL: FINANCE_API, VITE_AUTH_BASE_PATH: "/api/v1", VITE_CLIENT_APP: "", }, }, ], use: { trace: "retain-on-failure", screenshot: "only-on-failure", actionTimeout: 15_000, launchOptions: { slowMo: Number(process.env.SLOWMO ?? 0) }, }, /** * ONE PROJECT PER APP, not per persona. * * Per-persona projects were the first design and were wrong: a project's * `testMatch` selects files, so every HR spec ran once under each of the four * HR personas. A gating scenario written for `hr-employee` then also ran as * `hr-manager` and failed — not because the gate was broken, but because it * was asserted against the wrong role. Worse, the inverse would pass silently. * * The persona is a property of the SCENARIO, so each `describe` declares it: * * test.use({ storageState: storageFor("hr-employee") }) * * The project only supplies what is genuinely per-app: the baseURL. */ projects: [ { name: "hr", testMatch: /specs\/hr\/.*\.spec\.ts/, use: { ...devices["Desktop Chrome"], baseURL: HR_WEB }, }, { name: "finance", testMatch: /specs\/finance\/.*\.spec\.ts/, use: { ...devices["Desktop Chrome"], baseURL: FINANCE_WEB }, }, ], }); /** Path to a persona's storageState — every describe block names its own. */ export const storageFor = (personaKey: string): string => { if (!PERSONAS.some((p) => p.key === personaKey)) { throw new Error(`Unknown persona "${personaKey}"`); } return path.join(STORAGE, `${personaKey}.json`); };