import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { existsSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const currentDir = dirname(fileURLToPath(import.meta.url)); const workspaceRoot = resolve(currentDir, "../../.."); const webRoot = resolve(currentDir, ".."); const runtimeEnvPath = resolve(currentDir, ".playwright-runtime.json"); const webEnvLocal = resolve(webRoot, ".env.local"); const webEnvBackup = resolve(webRoot, ".env.local.e2e-backup"); const webDistDir = ".next-e2e"; const webDistDirPath = resolve(webRoot, webDistDir); const managedEnvBanner = "# Managed by apps/web/e2e/test-server.mjs"; const e2ePort = process.env.PLAYWRIGHT_TEST_PORT ?? "3110"; const e2eBaseUrl = process.env.PLAYWRIGHT_TEST_BASE_URL ?? `http://localhost:${e2ePort}`; const e2eAuthSecret = process.env.PLAYWRIGHT_AUTH_SECRET ?? `capakraken-e2e-${randomBytes(24).toString("hex")}`; const manageWebEnvFile = process.env.PLAYWRIGHT_MANAGE_WEB_ENV_FILE === "true"; const composeProjectName = `capakraken-e2e-${process.pid}`; const managedEnvKeys = [ "DATABASE_URL", "REDIS_URL", "NEXTAUTH_URL", "NEXTAUTH_SECRET", "AUTH_SECRET", "E2E_TEST_MODE", "NODE_ENV", "PORT", ]; const e2eComposePrefix = "capakraken-e2e-"; function dockerComposeArgs(...args) { return ["compose", "-p", composeProjectName, ...args]; } function loadEnvFile(filePath) { const env = {}; try { const contents = readFileSync(filePath, "utf8"); for (const rawLine of contents.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line || line.startsWith("#")) { continue; } const separatorIndex = line.indexOf("="); if (separatorIndex <= 0) { continue; } const key = line.slice(0, separatorIndex).trim(); const rawValue = line.slice(separatorIndex + 1).trim(); const quoted = (rawValue.startsWith("\"") && rawValue.endsWith("\"")) || (rawValue.startsWith("'") && rawValue.endsWith("'")); env[key] = quoted ? rawValue.slice(1, -1) : rawValue; } } catch { // Keep local runs working even when no workspace .env is present. } return env; } function applyEnv(env) { for (const [key, value] of Object.entries(env)) { process.env[key] = value; } } function writeManagedWebEnv(rootEnv) { if (!manageWebEnvFile) { restoreWebEnv(); return; } if (existsSync(webEnvBackup) && isManagedEnvFile(webEnvBackup)) { rmSync(webEnvBackup, { force: true }); } if (existsSync(webEnvLocal)) { if (isManagedEnvFile(webEnvLocal)) { rmSync(webEnvLocal, { force: true }); } else { if (existsSync(webEnvBackup)) { rmSync(webEnvBackup, { force: true }); } renameSync(webEnvLocal, webEnvBackup); } } const contents = managedEnvKeys .map((key) => { const value = process.env[key] ?? rootEnv[key]; return value ? `${key}=${value}` : null; }) .filter(Boolean) .join("\n"); writeFileSync(webEnvLocal, `${managedEnvBanner}\n${contents}\n`, "utf8"); } function restoreWebEnv() { if (existsSync(webEnvLocal) && isManagedEnvFile(webEnvLocal)) { rmSync(webEnvLocal, { force: true }); } if (existsSync(webEnvBackup)) { if (isManagedEnvFile(webEnvBackup)) { rmSync(webEnvBackup, { force: true }); } else { renameSync(webEnvBackup, webEnvLocal); } } } let restoredManagedEnv = false; function restoreWebEnvOnce() { if (restoredManagedEnv) { return; } restoredManagedEnv = true; restoreWebEnv(); rmSync(runtimeEnvPath, { force: true }); } function isManagedEnvFile(filePath) { try { const contents = readFileSync(filePath, "utf8"); return contents.includes(managedEnvBanner) || contents.includes("E2E_TEST_MODE=true"); } catch { return false; } } function run(command, args, cwd) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn(command, args, { cwd, env: process.env, stdio: "inherit", }); child.on("error", rejectPromise); child.on("exit", (code) => { if (code === 0) { resolvePromise(); return; } rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`)); }); }); } function runQuiet(command, args, cwd) { return new Promise((resolvePromise, rejectPromise) => { const child = spawn(command, args, { cwd, env: process.env, stdio: "ignore", }); child.on("error", rejectPromise); child.on("exit", (code) => { if (code === 0) { resolvePromise(); return; } rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "null"}`)); }); }); } function runCapture(command, args, cwd) { return new Promise((resolvePromise, rejectPromise) => { let stdout = ""; let stderr = ""; const child = spawn(command, args, { cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"], }); child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", rejectPromise); child.on("exit", (code) => { if (code === 0) { resolvePromise(stdout); return; } rejectPromise( new Error( `${command} ${args.join(" ")} exited with code ${code ?? "null"}${stderr ? `: ${stderr.trim()}` : ""}`, ), ); }); }); } async function cleanupStaleE2eArtifacts() { try { const containerOutput = await runCapture("docker", ["ps", "-a", "--format", "{{.Names}}"], workspaceRoot); const staleContainers = containerOutput .split(/\r?\n/u) .map((value) => value.trim()) .filter((name) => name.startsWith(e2eComposePrefix)); if (staleContainers.length > 0) { await runQuiet("docker", ["rm", "-f", ...staleContainers], workspaceRoot); } } catch { // Best-effort cleanup only. } try { const networkOutput = await runCapture("docker", ["network", "ls", "--format", "{{.Name}}"], workspaceRoot); const staleNetworks = networkOutput .split(/\r?\n/u) .map((value) => value.trim()) .filter((name) => name.startsWith(e2eComposePrefix)); if (staleNetworks.length > 0) { await runQuiet("docker", ["network", "rm", ...staleNetworks], workspaceRoot); } } catch { // Best-effort cleanup only. } } async function ensureE2eDatabaseContainer() { try { await runQuiet("docker", dockerComposeArgs("rm", "-sf", "postgres-test"), workspaceRoot); } catch { // No previous test container to remove. } await run("docker", dockerComposeArgs("--profile", "test", "up", "-d", "--force-recreate", "postgres-test"), workspaceRoot); const maxAttempts = 30; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { await runQuiet( "docker", dockerComposeArgs("exec", "-T", "postgres-test", "pg_isready", "-U", "capakraken", "-d", "capakraken_test", "-q"), workspaceRoot, ); return; } catch (error) { if (attempt === maxAttempts) { throw error; } await new Promise((resolvePromise) => setTimeout(resolvePromise, 1000)); } } } function parseDatabaseName(databaseUrl) { const parsed = new URL(databaseUrl); return parsed.pathname.replace(/^\/+/u, ""); } async function canBindPort(port) { return new Promise((resolvePromise) => { const server = createServer(); server.once("error", () => { resolvePromise(false); }); server.once("listening", () => { server.close(() => resolvePromise(true)); }); server.listen(port, "127.0.0.1"); }); } async function selectAvailablePort(preferredPort) { const candidates = [ preferredPort, ...Array.from({ length: 50 }, (_, index) => preferredPort + index + 1), ]; for (const candidate of candidates) { if (await canBindPort(candidate)) { return candidate; } } throw new Error(`No free host port available for postgres-test near ${preferredPort}.`); } function replaceDatabasePort(databaseUrl, port) { const parsed = new URL(databaseUrl); parsed.port = String(port); return parsed.toString(); } let cleanedUpComposeProject = false; async function cleanupComposeProject() { if (cleanedUpComposeProject) { return; } cleanedUpComposeProject = true; try { await runQuiet("docker", dockerComposeArgs("down", "--remove-orphans"), workspaceRoot); } catch { // Best-effort cleanup only. } } const rootEnv = loadEnvFile(resolve(workspaceRoot, ".env")); applyEnv(rootEnv); let playwrightDatabaseUrl = process.env.PLAYWRIGHT_DATABASE_URL ?? process.env.DATABASE_URL_TEST; if (!playwrightDatabaseUrl) { throw new Error("PLAYWRIGHT_DATABASE_URL or DATABASE_URL_TEST must be configured for E2E runs."); } // CI mode: use an externally-provided postgres (e.g. a GitHub Actions service // container) instead of spinning up our own compose-managed postgres-test. // In that mode we trust PLAYWRIGHT_DATABASE_URL as-is — no port rebinding, // no compose up. const useExternalDb = process.env.PLAYWRIGHT_USE_EXTERNAL_DB === "true"; let selectedTestDbPort; if (!useExternalDb) { const requestedTestDbPort = Number(new URL(playwrightDatabaseUrl).port || "5434"); selectedTestDbPort = await selectAvailablePort(requestedTestDbPort); playwrightDatabaseUrl = replaceDatabasePort(playwrightDatabaseUrl, selectedTestDbPort); } const playwrightDatabaseName = parseDatabaseName(playwrightDatabaseUrl); if (!/(^|_)(test|e2e|ci)$/u.test(playwrightDatabaseName)) { throw new Error( `Refusing to run E2E destructive setup against non-test database '${playwrightDatabaseName}'. Set PLAYWRIGHT_DATABASE_URL to an isolated test database.`, ); } process.env.DATABASE_URL = playwrightDatabaseUrl; process.env.PLAYWRIGHT_DATABASE_URL = playwrightDatabaseUrl; if (selectedTestDbPort !== undefined) { process.env.POSTGRES_TEST_PORT = String(selectedTestDbPort); } process.env.CAPAKRAKEN_EXPECTED_DB_NAME = playwrightDatabaseName; process.env.ALLOW_DESTRUCTIVE_DB_TOOLS = "true"; process.env.CONFIRM_DESTRUCTIVE_DB_NAME = playwrightDatabaseName; process.env.NODE_ENV = process.env.NODE_ENV ?? "development"; process.env.PORT = e2ePort; process.env.NEXTAUTH_URL = e2eBaseUrl; process.env.AUTH_URL = e2eBaseUrl; process.env.NEXTAUTH_SECRET = e2eAuthSecret; process.env.AUTH_SECRET = e2eAuthSecret; process.env.NEXT_DIST_DIR = webDistDir; process.env.E2E_TEST_MODE = "true"; writeFileSync( runtimeEnvPath, JSON.stringify( { DATABASE_URL: process.env.DATABASE_URL, PLAYWRIGHT_DATABASE_URL: process.env.PLAYWRIGHT_DATABASE_URL, POSTGRES_TEST_PORT: process.env.POSTGRES_TEST_PORT, BASE_URL: e2eBaseUrl, }, null, 2, ), "utf8", ); writeManagedWebEnv(rootEnv); process.on("exit", restoreWebEnvOnce); try { if (!useExternalDb) { await cleanupStaleE2eArtifacts(); await ensureE2eDatabaseContainer(); } await run("pnpm", ["--filter", "@capakraken/db", "db:push"], workspaceRoot); await run("pnpm", ["--filter", "@capakraken/db", "db:seed"], workspaceRoot); await run("pnpm", ["--filter", "@capakraken/db", "db:seed:holidays"], workspaceRoot); rmSync(webDistDirPath, { recursive: true, force: true }); const server = spawn("pnpm", ["exec", "next", "dev", "-p", e2ePort], { cwd: webRoot, env: process.env, stdio: "inherit", }); for (const signal of ["SIGINT", "SIGTERM"]) { process.on(signal, () => { restoreWebEnvOnce(); void cleanupComposeProject(); server.kill(signal); }); } server.on("exit", async (code) => { restoreWebEnvOnce(); await cleanupComposeProject(); process.exit(code ?? 0); }); } catch (error) { restoreWebEnvOnce(); await cleanupComposeProject(); throw error; }