Files
edr-platform/e2e-hr-finance/playwright.config.ts
2026-08-25 00:11:39 +03:00

157 lines
5.3 KiB
TypeScript

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`);
};