import type { Page, BrowserContext } from '@playwright/test'; import type { RouteAccess } from './websiteRouteInventory'; export type WebsiteAuthContext = 'public' | 'auth' | 'admin' | 'sponsor'; export type WebsiteSessionDriftMode = 'invalid-cookie' | 'expired' | 'missing-sponsor-id'; export type WebsiteFaultMode = 'null-array' | 'missing-field' | 'invalid-date'; const demoSessionCookieCache = new Map(); export function authContextForAccess(access: RouteAccess): WebsiteAuthContext { if (access === 'public') return 'public'; if (access === 'auth') return 'auth'; if (access === 'admin') return 'admin'; return 'sponsor'; } function getWebsiteBaseUrl(): string { const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL; if (configured && configured.trim()) { return configured.trim().replace(/\/$/, ''); } return 'http://localhost:3100'; } // Note: All authenticated contexts use the same seeded demo driver user // Role-based access control is tested separately in integration tests function extractCookieValue(setCookieHeader: string, cookieName: string): string | null { // set-cookie header value: "name=value; Path=/; HttpOnly; ..." // Do not split on comma (Expires contains commas). Just regex out the first cookie value. const match = setCookieHeader.match(new RegExp(`(?:^|\\s)${cookieName}=([^;]+)`)); return match?.[1] ?? null; } async function ensureNormalSessionCookie(): Promise { const cached = demoSessionCookieCache.get('driver'); if (cached) return cached; const baseUrl = getWebsiteBaseUrl(); const url = `${baseUrl}/api/auth/login`; const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', }, body: JSON.stringify({ email: 'demo.driver@example.com', password: 'Demo1234!', }), }); if (!response.ok) { const body = await response.text().catch(() => ''); throw new Error(`Normal login failed. ${response.status} ${response.statusText}. ${body}`); } // In Node (playwright runner) `headers.get('set-cookie')` returns a single comma-separated string. // Parse cookies by splitting on `, ` and taking the first `name=value` segment. const rawSetCookie = response.headers.get('set-cookie') ?? ''; const cookieHeaderPairs = rawSetCookie ? rawSetCookie .split(', ') .map((chunk) => chunk.split(';')[0]?.trim()) .filter(Boolean) : []; const gpSessionPair = cookieHeaderPairs.find((pair) => pair.startsWith('gp_session=')); if (!gpSessionPair) { throw new Error( `Normal login did not return gp_session cookie. set-cookie header: ${rawSetCookie}`, ); } const gpSessionValue = extractCookieValue(gpSessionPair, 'gp_session'); if (!gpSessionValue) { throw new Error( `Normal login returned a gp_session cookie, but it could not be parsed. Pair: ${gpSessionPair}`, ); } demoSessionCookieCache.set('driver', gpSessionValue); return gpSessionValue; } export async function setWebsiteAuthContext( context: BrowserContext, auth: WebsiteAuthContext, options: { sessionDrift?: WebsiteSessionDriftMode; faultMode?: WebsiteFaultMode } = {}, ): Promise { const domain = 'localhost'; const base = { domain, path: '/' }; const driftCookie = options.sessionDrift != null ? [{ ...base, name: 'gridpilot_session_drift', value: String(options.sessionDrift) }] : []; const faultCookie = options.faultMode != null ? [{ ...base, name: 'gridpilot_fault_mode', value: String(options.faultMode) }] : []; await context.clearCookies(); if (auth === 'public') { // Public access: no session cookie, only drift/fault cookies if specified await context.addCookies([...driftCookie, ...faultCookie]); return; } // For authenticated contexts, use normal login with seeded demo user // Note: All auth contexts use the same seeded demo driver user for simplicity // Role-based access control is tested separately in integration tests const gpSessionValue = await ensureNormalSessionCookie(); // Only set gp_session cookie (no demo mode or sponsor cookies) // For Docker/local testing, ensure cookies work with localhost const sessionCookie = [{ ...base, name: 'gp_session', value: gpSessionValue, httpOnly: true, secure: false, // Localhost doesn't need HTTPS sameSite: 'Lax' as const // Ensure compatibility }]; await context.addCookies([...sessionCookie, ...driftCookie, ...faultCookie]); } export type ConsoleCapture = { consoleErrors: string[]; pageErrors: string[]; }; export function attachConsoleErrorCapture(page: Page): ConsoleCapture { const consoleErrors: string[] = []; const pageErrors: string[] = []; page.on('pageerror', (err) => { pageErrors.push(String(err)); }); page.on('console', (msg) => { const type = msg.type(); if (type !== 'error') return; const text = msg.text(); // Filter known benign warnings (keep small + generic). if (text.includes('Download the React DevTools')) return; // Next/Image accessibility warning (not a runtime failure for smoke coverage). if (text.includes('Image is missing required "alt" property')) return; // React controlled instead of setting `selected` on