import * as fs from "node:fs"; import * as path from "node:path"; import { expect, test as setup } from "@playwright/test"; import { applyVideoSafetyMode, TEST_USER } from "./fixtures"; const authFile = path.join(import.meta.dirname, ".auth", "user.json"); /** * Check if a JWT token is still valid (not expired) without making a * network request. Returns `true` when the token has at least 2 minutes * of remaining validity. */ function isTokenValid(token: string): boolean { try { const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString()); // Require at least 10 minutes of remaining validity to ensure the token // lasts through the entire test run (which can take 7+ minutes) return typeof payload.exp === "number" && Date.now() / 1000 < payload.exp - 600; } catch { return false; } } /** * Global setup: ensure a test user exists and persist authenticated state. * Runs once before all test projects. * * Strategy: * 1. If a valid auth file exists whose access_token JWT has not expired, * reuse it without any network call (saves rate-limit budget). * 2. If auth is disabled (no login page), save state immediately. * 3. Try to register via API (idempotent — fails silently if user exists). * 4. Log in via the UI. */ setup("authenticate", async ({ page }) => { await applyVideoSafetyMode(page); // Create .auth directory if it doesn't exist const authDir = path.dirname(authFile); if (!fs.existsSync(authDir)) { fs.mkdirSync(authDir, { recursive: true }); } // ---- 1. Try to reuse an existing auth file (offline check) ---- if (fs.existsSync(authFile)) { try { const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")); const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token"); if (accessCookie?.value && isTokenValid(accessCookie.value)) { // Token still has enough validity — skip login entirely return; } } catch { // Invalid file — fall through to regular login } } // ---- 2. Check if auth is disabled ---- await page.goto("/"); const authDisabled = await page .locator("header.hero") .isVisible() .catch(() => false); if (authDisabled) { await page.context().storageState({ path: authFile }); return; } // Wait for auth container await expect(page.locator(".auth-container")).toBeVisible({ timeout: 15000 }); // ---- 3. Query auth state to determine login method ---- const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173"; let formLoginEnabled = true; let oidcEnabled = false; try { const stateRes = await page.request.get(`${baseURL}/api/auth/state`); if (stateRes.ok()) { const state = await stateRes.json(); formLoginEnabled = state.formLoginEnabled !== false; oidcEnabled = state.oidcEnabled === true; } } catch { // Fallback: assume form login is available } // ---- 4. Ensure the test user exists (only if form login is available) ---- if (formLoginEnabled) { await page.request .post(`${baseURL}/api/auth/register`, { data: { username: TEST_USER.username, password: TEST_USER.password }, }) .catch(() => {}); } // ---- 5. Log in via the appropriate method ---- if (formLoginEnabled) { // Form login path: username/password const usernameField = page.locator("#username"); const passwordField = page.locator("#password"); // Make sure we're on the login form (not register) const isOnRegister = await page .locator(".auth-subtitle") .filter({ hasText: /Create Account/i }) .isVisible() .catch(() => false); if (isOnRegister) { const switchBtn = page.locator("button.auth-link-btn"); if (await switchBtn.isVisible().catch(() => false)) { await switchBtn.click(); await page.waitForTimeout(500); } } await usernameField.clear(); await usernameField.fill(TEST_USER.username); await passwordField.clear(); await passwordField.fill(TEST_USER.password); // Click the submit button (not the SSO button) await page.locator('button.auth-submit[type="submit"]').click(); } else if (oidcEnabled) { // SSO-only path: click the SSO button and let the OIDC provider handle login. // This requires the OIDC provider to be configured with test credentials // (e.g. via PLAYWRIGHT_OIDC_USERNAME / PLAYWRIGHT_OIDC_PASSWORD env vars) // or to auto-approve the test user. await page.locator("button.sso-btn").click(); // Wait for OIDC redirect and callback — the provider may show its own login form const oidcUsername = process.env.PLAYWRIGHT_OIDC_USERNAME; const oidcPassword = process.env.PLAYWRIGHT_OIDC_PASSWORD; if (oidcUsername && oidcPassword) { // Fill OIDC provider login form (generic selectors — override if needed) await page.waitForURL(/.*/, { timeout: 15000 }); const oidcUserField = page.locator('input[name="username"], input[name="login"], input[type="email"]').first(); const oidcPassField = page.locator('input[name="password"], input[type="password"]').first(); if (await oidcUserField.isVisible({ timeout: 10000 }).catch(() => false)) { await oidcUserField.fill(oidcUsername); await oidcPassField.fill(oidcPassword); await page.locator('button[type="submit"]').first().click(); } } } else { throw new Error("No login method available: form login and OIDC are both disabled"); } // Wait for successful auth — app header should appear await expect(page.locator("header.hero")).toBeVisible({ timeout: 15000 }); // Persist authenticated state for all test projects await page.context().storageState({ path: authFile }); });