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"; export type Actor = "user"; const ACTOR_PERMISSIONS: Record = { user: [ "user-management:user", "user-management:role", "user-management:roleAssignment", "user-management:profile", ], }; export { ACTOR_PERMISSIONS }; // `baseURL` is test-scoped, so the worker fixture below 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 (Playwright's "one // account per parallel worker"). Tests in a worker run serially, so the shared // account is never used concurrently. 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`, ); // newContext inherits the project storageState option; start clean and // let LoginPage sign in, then persist the session for this worker. 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 file with `useActor(test, actor)`. actor: ["user", { 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.getByRole("link", { name: /users/i }); 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 }); }