import { chromium, type FullConfig } from "@playwright/test"; import * as fs from "node:fs"; import * as path from "node:path"; import { PERSONAS, type Persona } from "./fixtures/personas"; /** * Mints one storageState per persona before any spec runs. * * Both web apps keep their session in COOKIES (`auth-token`, `refresh-token`, * `auth-user`) rather than localStorage — deliberately the same names * freight-backoffice uses, so the apps share one IAM session. storageState is * therefore cookie-shaped, and `auth-user` must be present: AuthContext seeds * itself from that cookie so a reload does not flash the login screen, and * without it the app renders logged-out for the first paint of every test. * * Most personas are minted programmatically (a direct POST to /auth/login) — * eight real UI logins would add ~30s to every run and prove the same thing * eight times. ONE persona per app goes through the real form, so * the login UI itself stays covered. That split follows the passenger suite. * * Fixture data (permissions, roles, personas) is seeded by run.sh before this * runs; this file only mints sessions. If a login fails here it is almost * always because the seeders were skipped — the error says so rather than * letting 20 specs fail with a confusing redirect to /login. */ const STORAGE = path.join(__dirname, "fixtures", "storage"); 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 apiFor = (p: Persona) => (p.app === "hr" ? HR_API : FINANCE_API); const webFor = (p: Persona) => (p.app === "hr" ? HR_WEB : FINANCE_WEB); /** The apps read these three; `auth-user` is what AuthContext seeds from. */ const cookiesFor = (origin: string, token: string, refreshToken: string, user: unknown) => { const { hostname } = new URL(origin); const base = { domain: hostname, path: "/", expires: -1, httpOnly: false, secure: false, sameSite: "Lax" as const }; return [ { ...base, name: "auth-token", value: token }, { ...base, name: "refresh-token", value: refreshToken }, { ...base, name: "auth-user", value: encodeURIComponent(JSON.stringify(user)) }, ]; }; async function mintViaApi(persona: Persona): Promise { const api = apiFor(persona); const res = await fetch(`${api}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, // The login DTO validates `email` — it accepts a username in that field // too, but the field name is `email` and an omitted one 400s with // "email should not be empty". body: JSON.stringify({ email: persona.email, password: persona.password }), }); if (!res.ok) { throw new Error( `[global-setup] login failed for ${persona.key} (${persona.email}): HTTP ${res.status}. ` + `Did run.sh seed the personas? ` + `DB_NAME=smart_office_e2e node e2e-hr-finance/fixtures/seed-personas.cjs`, ); } const { token, refreshToken } = (await res.json()) as { token: string; refreshToken: string }; const meRes = await fetch(`${api}/api/v1/me`, { headers: { Authorization: `Bearer ${token}` } }); if (!meRes.ok) throw new Error(`[global-setup] /me failed for ${persona.key}: HTTP ${meRes.status}`); const me = await meRes.json(); const state = { cookies: cookiesFor(webFor(persona), token, refreshToken, me), origins: [] }; fs.writeFileSync(path.join(STORAGE, `${persona.key}.json`), JSON.stringify(state, null, 2)); } /** One per app, through the real form, so itself is covered. */ async function mintViaUi(persona: Persona): Promise { const browser = await chromium.launch(); try { const ctx = await browser.newContext(); const page = await ctx.newPage(); await page.goto(`${webFor(persona)}/login`, { waitUntil: "domcontentloaded" }); // Mantine renders the password field with a visibility-toggle BUTTON that // also carries the accessible name "Password", so getByLabel matches two // elements. Address the input by role instead. await page.getByLabel("Email or username").fill(persona.email); await page.getByRole("textbox", { name: "Password" }).fill(persona.password); await Promise.all([ page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 }), page.getByRole("button", { name: "Sign in" }).click(), ]); await ctx.storageState({ path: path.join(STORAGE, `${persona.key}.json`) }); } finally { await browser.close(); } } export default async function globalSetup(_config: FullConfig) { fs.mkdirSync(STORAGE, { recursive: true }); const uiMinted = new Set(["hr-manager", "finance-manager"]); for (const persona of PERSONAS) { if (uiMinted.has(persona.key)) { await mintViaUi(persona); console.log(`[global-setup] ${persona.key.padEnd(24)} minted via real login UI`); } else { await mintViaApi(persona); console.log(`[global-setup] ${persona.key.padEnd(24)} minted via API`); } } console.log(`[global-setup] ${PERSONAS.length} storageStates written to fixtures/storage/`); }