/** * AI Software Factory — Playwright fixtures. * * Extends the base test with: * user — the authenticated demo user (email + name). * api — an authenticated APIRequestContext (baseURL = API). * authPage — a fresh browser Page in a context seeded with the demo * user's cookies (fresh login per test). * secondUser — a SECOND authenticated user, registered fresh for the * test (email + password) — for invites, comments, * sharing, permission-denied checks. * freshWorkspace — a per-test helper: `const ws = await freshWorkspace()` * creates a workspace via the API and deletes it in the * fixture teardown (best effort). * * AUTH MODEL (S10): the access token is Bearer-only (JS memory on the web * client, header on the API context) and the httpOnly `factory_refresh` * cookie is the rotating session source of truth. Concurrency rule: * EVERY fixture that needs the demo user performs a FRESH login (a new * session per test) instead of reusing the shared .auth/user.json cookie — * two browser contexts silently refreshing the SAME refresh cookie would * trip the API's reuse-detection and revoke the whole session family, * nuking parallel specs. .auth/user.json (global-setup) is therefore only * consumed by single-context specs (collab-two-tab) which own that cookie * exclusively. * * Selector convention (shared with apps/web; see tests/README.md): * data-testid="block-{id}" any block * data-testid="drag-handle-{id}" block drag handle * data-testid="slash-menu" slash command menu * data-testid="sidebar-page-{id}" sidebar page node * data-testid="sidebar-new-page" "New page" button * ... (full list in tests/README.md §Selector convention) */ import { test as base, expect, request as pwRequest, type APIRequestContext, type Page } from "@playwright/test"; import * as fs from "node:fs"; import * as path from "node:path"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- export const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000"; const WEB_BASE = process.env.E2E_BASE_URL ?? "http://localhost:3000"; const AUTH_FILE = path.join(__dirname, ".auth", "user.json"); // Demo user credentials (kept in sync with prisma/seed.ts). export const DEMO_EMAIL = process.env.E2E_DEMO_EMAIL ?? "demo@factory.local"; export const DEMO_PASSWORD = process.env.E2E_DEMO_PASSWORD ?? "Factory!2345"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface AuthState { /** * The user's access token captured at login (Bearer surface). Optional for * hand-built browser storage states (presence-cursors): the browser only * needs the cookie — the token is metadata for the Bearer contexts. */ accessToken?: string; cookies: { name: string; value: string; domain: string; path: string; expires: number; httpOnly: boolean; secure: boolean; sameSite: "Strict" | "Lax" | "None"; }[]; origins: { origin: string; localStorage: { name: string; value: string }[] }[]; } export interface Workspace { id: string; name: string; slug: string; } export interface PageDto { id: string; workspaceId: string; parentId: string | null; title: string; position: string; } export interface BlockDto { id: string; pageId: string; type: string; position: string; content: unknown; properties: unknown; } export interface RecordDto { id: string; workspaceId: string; databaseId: string; pageId: string | null; position: string; properties: { propertyId: string; value: unknown }[]; } export interface CommentDto { id: string; workspaceId: string; pageId: string | null; blockId: string | null; body: string; status: string; createdBy: string; } // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- type FactoryFixtures = { /** The demo user this suite authenticates as. */ user: { email: string; password: string }; /** Authenticated API context (fresh demo login per test — Bearer). */ api: APIRequestContext; /** Authenticated browser page (fresh context per test, fresh login). */ authPage: Page; /** * A second, freshly-registered user with its own authenticated page + api. * Created lazily: `const { page, api, user } = await secondUser()`. */ secondUser: () => Promise<{ user: { email: string; password: string }; page: Page; api: APIRequestContext; }>; /** * Create a workspace via the API. Every call is tracked and the workspaces * are deleted in teardown (best effort). */ freshWorkspace: (name?: string) => Promise; }; export function loadAuthState(): AuthState { if (!fs.existsSync(AUTH_FILE)) { throw new Error( `Missing auth state at ${AUTH_FILE} — did global-setup run? ` + "(Check the API is up: pnpm docker:up && pnpm dev)", ); } return JSON.parse(fs.readFileSync(AUTH_FILE, "utf8")) as AuthState; } // Exported (not just internal) so specs that drive the API from a plain // helper — rather than through the `api` fixture — can build a Bearer context // the same way. S10: the access token is Bearer-only; a cookie-only // `page.request` context 401s on every write. (editor.spec openFreshPage, // T-192.) export async function bearerApi(token: string): Promise { // A bare @playwright/test APIRequestContext (Playwright 1.49 has no // BrowserContext.request.newContext and APIRequestContext has no // setExtraHTTPHeaders) with a FIXED Bearer header — the web client's // access token is memory-only, so the API context carries the token in // the Authorization header, never in storage. return pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000, extraHTTPHeaders: { Authorization: `Bearer ${token}` }, }); } export async function freshLoginToken(email: string, password: string): Promise { const req = await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 }); try { const res = await req.post("/api/v1/auth/login", { data: { email, password } }); if (res.status() !== 200) { throw new Error(`login ${email} failed (${res.status()}): ${await res.text()}`); } const body = (await res.json()) as { accessToken?: string }; if (!body.accessToken) throw new Error(`login ${email}: no accessToken in body`); return body.accessToken; } finally { await req.dispose(); } } function uniqueEmail(): string { return `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; } export const test = base.extend({ user: async ({}, use) => { await use({ email: DEMO_EMAIL, password: DEMO_PASSWORD }); }, // Fresh login per test: its own session, so parallel specs never share a // refresh cookie (reuse-detection safety — see header). api: async ({}, use) => { const token = await freshLoginToken(DEMO_EMAIL, DEMO_PASSWORD); const ctx = await bearerApi(token); await use(ctx); await ctx.dispose(); }, // Fresh browser context + fresh login → cookie jar → storage state. The // web client silent-refreshes on load (cookie → memory access token). // // TASK-0100 S5: the login POST must target the API origin explicitly. The // web calls the API directly (API_BASE = NEXT_PUBLIC_API_URL, default // http://localhost:4000/api/v1) — there is NO Next.js /api proxy — so a // relative "/api/v1/auth/login" resolved against the context's web baseURL // (E2E_BASE_URL, :3100) hit a Next page (500), not the API. Pin the context // baseURL to API_BASE for the login request. authPage: async ({ browser }, use) => { const plain = await browser.newContext({ baseURL: API_BASE }); const res = await plain.request.post("/api/v1/auth/login", { data: { email: DEMO_EMAIL, password: DEMO_PASSWORD }, }); if (res.status() !== 200) { throw new Error(`authPage: login failed (${res.status()}): ${await res.text()}`); } const body = (await res.json()) as { accessToken?: string }; const jar = await plain.cookies(); await plain.close(); const state: AuthState = { accessToken: body.accessToken ?? "", cookies: jar.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, })), origins: [], }; const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); await use(page); await context.close(); }, secondUser: async ({ browser }, use) => { let registered: | { user: { email: string; password: string }; page: Page; api: APIRequestContext; } | undefined; await use(async () => { if (registered) return registered; const email = uniqueEmail(); const password = `e2e-${Date.now().toString(36)}`; // Register + login against a CLEAN browser context (no demo state); // context.request shares the context's cookie jar, so the login's // Set-Cookie lands in `plain.cookies()`. // // TASK-0100 S5: the context MUST be pinned to the API origin (API_BASE). // Without a baseURL, `plain.request` falls back to Playwright's global // baseURL = the WEB app (E2E_BASE_URL, :3100). The web returns the // login page (200 HTML) for any path, so the register "succeeded" // (200) WITHOUT creating a user — and the internal seam then correctly // 404'd "User not found — register the user first". Mirrors the authPage // fixture's context (line above), which already sets baseURL: API_BASE. const plain = await browser.newContext({ baseURL: API_BASE }); const regApi = plain.request; const reg = await regApi.post("/api/v1/auth/register", { data: { email, password, name: "E2E Second User" }, }); if (![200, 201].includes(reg.status())) { throw new Error( `secondUser: register failed (${reg.status()}): ${await reg.text()}`, ); } const login = await regApi.post("/api/v1/auth/login", { data: { email, password }, }); if (login.status() !== 200) { throw new Error( `secondUser: login failed (${login.status()}): ${await login.text()}`, ); } // Storage state for the second user's browser: cookies only. The // access token is NOT mirrored into localStorage — the web client // keeps it in JS memory and re-issues it from the refresh cookie on // load (S10; the localStorage mirror was the stale Phase-1 contract). const jar = await plain.cookies(); const state: AuthState = { accessToken: "", cookies: jar.map((c) => ({ name: c.name, value: c.value, domain: c.domain, path: c.path, expires: c.expires, httpOnly: c.httpOnly, secure: c.secure, sameSite: c.sameSite, })), origins: [], }; let token = ""; try { const body = (await login.json()) as { accessToken?: string }; token = body.accessToken ?? ""; state.accessToken = token; } catch { /* login body without a token — the cookie still authenticates */ } await plain.close(); const context = await browser.newContext({ storageState: state }); const page = await context.newPage(); // A token-less API context would be Bearer-less (all protected calls // 401) — only reachable if the login body lacked an accessToken, which // the contract forbids; keep the fallback explicit for the type system. const api = token ? await bearerApi(token) : await pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 }); registered = { user: { email, password }, page, api }; return registered; }); if (registered) { await registered.api.dispose().catch(() => {}); // The context is owned by Playwright (browser teardown closes it); // dispose the api context explicitly to be safe. } }, freshWorkspace: async ({ api }, use) => { const created: Workspace[] = []; await use(async (name?: string) => { const res = await api.post("/api/v1/workspaces", { data: { name: name ?? `E2E WS ${Date.now()}` }, }); const text = await res.text(); expect( res.status(), `freshWorkspace: create failed (${res.status()}): ${text}`, ).toBe(201); // TASK-0100 S5: the API returns the BARE workspace + role (workspaces // .service.ts `create` → `{ ...workspace, role: "OWNER" }`), NOT a // `{ workspace }` wrapper — the old `body.workspace` read was // undefined and crashed every consumer of this fixture. const ws = JSON.parse(text) as Workspace; created.push(ws); return ws; }); // Teardown: delete in reverse creation order; swallow failures (the // workspace may have been deleted/purged by the test itself). for (const ws of created.reverse()) { await api.delete(`/api/v1/workspaces/${ws.id}`).catch(() => {}); } }, }); export { expect };