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

115 lines
5.2 KiB
TypeScript

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 <LoginPage> 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<void> {
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 <LoginPage> itself is covered. */
async function mintViaUi(persona: Persona): Promise<void> {
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/`);
}