import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { test as base, expect } from "@playwright/test"; import { LoginPage } from "../pages/auth/login"; import { createUser, type TestUser } from "../utils/user-factory"; /** * Shared e2e auth, mirroring the product-management template. Each flow signs in * for real through the app's OAuth hosted login (LoginPage) so the session is a * proper DPoP-bound one — a machine-user token injected into IndexedDB cannot * authenticate because @tailor-platform/auth-public-client always sends * `Authorization: DPoP` bound to the stored key. * * The erp template does not enforce separation of duties, so a single broadly * permissioned "admin" actor drives every flow. Add narrower actors here if a * flow needs to assert permission boundaries. */ export type Actor = | "admin" | "approver" | "inventory-manager" | "purchaser" | "sales-rep" | "store-staff"; // Mirrors the seeded Admin role (backend/seed/data/Role.jsonl) minus the // sales:shipment scope removed from erp-kit in 0.45. const ADMIN_PERMISSIONS: string[] = [ "user-management:profile", "user-management:role", "user-management:roleAssignment", "user-management:user", "organization:company", "organization:organizationUnit", "business-partner:partner", "business-partner:partnerDetail", "item-management:item", "item-management:taxonomy", "primitives:currency", "primitives:unit", "primitives:category", "inventory:location", "inventory:inventoryMaster", "inventory:inventoryControl", "inventory:inventoryApproval", "inventory:inventoryPosting", "inventory:stockReservation", "inventory:supplyPlan", "inventory:transferOrder", "inbound-shipment:inboundShipmentOperation", "outbound-shipment:outboundShipmentOperation", "purchase:purchaseOrder", "product-management:product", "product-management:productCatalog", "account-payable:accountPayableDocument", "coa-management:accountManagement", "financial-accounting:journalEntry", "financial-accounting:periodManagement", "sales:salesOrder", ]; // The distinct actor names mirror docs/actor so specs can read as the intended // role, but the erp template does not enforce separation of duties, so every // actor is granted the same broad permission set for e2e. const ACTOR_PERMISSIONS: Record = { admin: ADMIN_PERMISSIONS, approver: ADMIN_PERMISSIONS, "inventory-manager": ADMIN_PERMISSIONS, purchaser: ADMIN_PERMISSIONS, "sales-rep": ADMIN_PERMISSIONS, "store-staff": ADMIN_PERMISSIONS, }; export { ACTOR_PERMISSIONS }; // `baseURL` is test-scoped, so the worker fixture reads it from env instead. function resolveBaseURL(): string { return process.env.PLAYWRIGHT_BASE_URL ?? process.env.E2E_BASE_URL ?? "http://localhost:5173"; } interface TestFixtures { actor: Actor; user: TestUser; } interface WorkerFixtures { signInAs: (actor: Actor) => Promise<{ user: TestUser; storageState: string }>; } export const test = base.extend({ // One account + sign-in per (worker, actor), created lazily and reused across // the worker's tests. parallelIndex keeps the account unique per worker, so // the same account never logs in from two workers at once. signInAs: [ async ({ browser }, use, workerInfo) => { const cache = new Map(); const signIn = async (actor: Actor) => { const hit = cache.get(actor); if (hit) return hit; const user = await createUser({ permissions: ACTOR_PERMISSIONS[actor] }); const storageState = path.join( os.tmpdir(), `e2e-auth-w${workerInfo.parallelIndex}-${actor}.json`, ); const context = await browser.newContext({ baseURL: resolveBaseURL(), storageState: { cookies: [], origins: [] }, }); const page = await context.newPage(); await new LoginPage(page).login(user.email, user.password); await context.storageState({ path: storageState }); await context.close(); const entry = { user, storageState }; cache.set(actor, entry); return entry; }; await use(signIn); for (const { storageState } of cache.values()) { fs.rmSync(storageState, { force: true }); } }, { scope: "worker" }, ], // Default actor; override per spec with `useActor(test, actor)`. actor: ["admin", { option: true }], // Restore this worker's session for the selected actor. storageState: async ({ actor, signInAs }, use) => { const { storageState } = await signInAs(actor); await use(storageState); }, // The signed-in user (e.g. to assert one's own email). user: async ({ actor, signInAs }, use) => { const { user } = await signInAs(actor); await use(user); }, // Start each test signed in; re-login (and refresh the saved state) if the // restored session was rejected. page: async ({ page, actor, signInAs }, use) => { await page.goto("/"); const loggedIn = page.getByText("Procurement", { exact: true }).first(); const signInButton = page.getByRole("button", { name: "Sign in" }); let needsLogin: boolean; try { await expect(loggedIn.or(signInButton)).toBeVisible({ timeout: 30_000 }); needsLogin = await signInButton.isVisible(); } catch { needsLogin = true; } if (needsLogin) { const { user, storageState } = await signInAs(actor); await new LoginPage(page).login(user.email, user.password); await page.context().storageState({ path: storageState }); } await use(page); }, }); export { expect }; /** The base test or a per-spec extension of it. */ export type AppTest = typeof test; /** Run this spec file's tests signed in as `actor`. */ export function useActor(test: AppTest, actor: Actor): void { test.use({ actor }); }