import { spawn, execFile as execFileCallback, type ChildProcess } from "node:child_process"; import { createWriteStream, type WriteStream } from "node:fs"; import { readFile, rm, writeFile } from "node:fs/promises"; import net from "node:net"; import { join, resolve } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { encodeBase64Url, sha256 } from "./helpers/token"; // Pattern is borrowed from sister projects (anvil, bland) — spin up an // isolated `vite dev` per E2E run with TESSERA_PERSIST_STATE_PATH pointing // at a fresh tempdir, write `.dev.vars.{unique-env}` next to the user's // `.dev.vars`, and pass CLOUDFLARE_ENV={env} so wrangler's bridge loads // the e2e file *instead of* the user's. The user's working copy is never // touched, even if the run crashes — the only side effect is the // per-run override file, which we delete on teardown. const execFile = promisify(execFileCallback); const REPO_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); const NPX_COMMAND = process.platform === "win32" ? "npx.cmd" : "npx"; const VITE_BIN_PATH = resolve(REPO_ROOT, "node_modules/vite/bin/vite.js"); const SERVER_READY_TIMEOUT_MS = 90_000; const POLL_INTERVAL_MS = 500; const DEV_VARS_PATH = resolve(REPO_ROOT, ".dev.vars"); const DEV_VARS_EXAMPLE_PATH = resolve(REPO_ROOT, ".dev.vars.example"); export interface E2eContext { tempDir: string; baseUrl: string; port: number; cloudflareEnv: string; devVarsPath: string; serverProcess: ChildProcess; stdoutLogPath: string; stderrLogPath: string; stdoutStream: WriteStream; stderrStream: WriteStream; } const closeWriteStream = async (stream: WriteStream): Promise => new Promise((resolveClose, reject) => { if (stream.destroyed || stream.closed || stream.writableFinished) { resolveClose(); return; } stream.end((error?: Error | null) => (error ? reject(error) : resolveClose())); }); export const getFreePort = async (): Promise => new Promise((resolvePort, reject) => { const server = net.createServer(); server.unref(); server.once("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (!address || typeof address === "string") { server.close(() => reject(new Error("Failed to allocate a free localhost port."))); return; } server.close((error?: Error | null) => (error ? reject(error) : resolvePort(address.port))); }); }); const execCommand = async (command: string, args: string[], env: NodeJS.ProcessEnv) => await execFile(command, args, { cwd: REPO_ROOT, env, maxBuffer: 10 * 1024 * 1024 }); const fetchStatus = async (url: string): Promise => { const res = await fetch(url, { signal: AbortSignal.timeout(3_000) }); await res.arrayBuffer(); return res.status; }; const waitForServerReady = async (baseUrl: string): Promise => { const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; let lastError: unknown = null; while (Date.now() < deadline) { try { // The asset shell answers 200 once vite finishes its first compile; // /api/auth/jwks confirms the worker side is wired up too. Both must // be green before we hand the URL to Playwright. const root = await fetchStatus(baseUrl); const jwks = await fetchStatus(`${baseUrl}/api/auth/jwks`); if (root === 200 && jwks === 200) return; } catch (e) { lastError = e; } await sleep(POLL_INTERVAL_MS); } throw new Error( `dev server readiness timed out: ${lastError instanceof Error ? lastError.message : String(lastError)}`, ); }; const loadDevVarsTemplate = async (): Promise => { for (const path of [DEV_VARS_PATH, DEV_VARS_EXAMPLE_PATH]) { try { return await readFile(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } throw new Error("Missing .dev.vars and .dev.vars.example — cannot seed e2e env"); }; const createE2eDevVarsFile = async (baseUrl: string): Promise<{ cloudflareEnv: string; devVarsPath: string }> => { // Unique per process so parallel runs (e.g. `playwright test` invoked // twice) don't stomp each other's e2e env files. const cloudflareEnv = `e2e-${process.pid}-${Date.now()}`; const devVarsPath = `${DEV_VARS_PATH}.${cloudflareEnv}`; const template = await loadDevVarsTemplate(); // Strip URLs from the template; we re-emit them at the bottom pinned to // the ephemeral port. Anything else (BETTER_AUTH_SECRET, TURNSTILE_*, // BOOTSTRAP_ADMIN_EMAIL) carries through as-is. const stripped = template .replace(/^BETTER_AUTH_URL=.*$/gm, "") .replace(/^OIDC_ISSUER=.*$/gm, "") .trimEnd(); const content = `${stripped}\nBETTER_AUTH_URL=${baseUrl}\nOIDC_ISSUER=${baseUrl}\n`; await writeFile(devVarsPath, content); return { cloudflareEnv, devVarsPath }; }; export const applyMigrations = async (persistTo: string): Promise => { await execCommand( NPX_COMMAND, ["wrangler", "d1", "migrations", "apply", "tessera-prod", "--local", "--persist-to", persistTo], { ...process.env, CI: "1", NO_D1_WARNING: "true" }, ); }; export interface SeededInvite { email: string; token: string; password: string; } const sqlString = (s: string) => `'${s.replace(/'/g, "''")}'`; export const seedInvite = async (persistTo: string, email: string): Promise => { const tokenBytes = new Uint8Array(32); crypto.getRandomValues(tokenBytes); const token = encodeBase64Url(tokenBytes); const tokenHash = await sha256(token); const id = crypto.randomUUID(); const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(); await execCommand( NPX_COMMAND, [ "wrangler", "d1", "execute", "tessera-prod", "--local", "--persist-to", persistTo, "--command", `INSERT INTO invites (id, token_hash, email, created_by, expires_at) VALUES (${sqlString(id)}, ${sqlString(tokenHash)}, ${sqlString(email)}, 'e2e-setup', ${sqlString(expiresAt)})`, ], { ...process.env, CI: "1", NO_D1_WARNING: "true" }, ); return { email, token, password: "correct-horse-battery-staple" }; }; export const startDevServer = async (persistTo: string): Promise => { const port = await getFreePort(); const baseUrl = `http://127.0.0.1:${port}`; const stdoutLogPath = join(persistTo, "dev.stdout.log"); const stderrLogPath = join(persistTo, "dev.stderr.log"); const stdoutStream = createWriteStream(stdoutLogPath, { flags: "a" }); const stderrStream = createWriteStream(stderrLogPath, { flags: "a" }); const { cloudflareEnv, devVarsPath } = await createE2eDevVarsFile(baseUrl); const serverProcess = spawn(process.execPath, [VITE_BIN_PATH, "dev", "--host", "127.0.0.1", "--port", String(port)], { cwd: REPO_ROOT, env: { ...process.env, TESSERA_PERSIST_STATE_PATH: persistTo, // wrangler/vite-plugin-cloudflare sees this and loads // `.dev.vars.{cloudflareEnv}` instead of `.dev.vars`. CLOUDFLARE_ENV: cloudflareEnv, }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", }); serverProcess.stdout?.pipe(stdoutStream); serverProcess.stderr?.pipe(stderrStream); try { await Promise.race([ waitForServerReady(baseUrl), new Promise((_, reject) => { serverProcess.once("exit", (code, signal) => { reject(new Error(`dev server exited before readiness (code=${code}, signal=${signal})`)); }); }), ]); } catch (error) { await stopDevServer(serverProcess); await closeWriteStream(stdoutStream); await closeWriteStream(stderrStream); await rm(devVarsPath, { force: true }); throw error; } return { tempDir: persistTo, baseUrl, port, cloudflareEnv, devVarsPath, serverProcess, stdoutLogPath, stderrLogPath, stdoutStream, stderrStream, }; }; export const stopDevServer = async (serverProcess: ChildProcess): Promise => { if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) return; const killServer = (signal: NodeJS.Signals): void => { if (serverProcess.pid === undefined) return; if (process.platform === "win32") { serverProcess.kill(signal); return; } process.kill(-serverProcess.pid, signal); }; killServer("SIGTERM"); const exited = await Promise.race([ new Promise((resolveExit) => serverProcess.once("exit", () => resolveExit(true))), sleep(10_000).then(() => false), ]); if (!exited && serverProcess.exitCode === null && serverProcess.signalCode === null) { killServer("SIGKILL"); await new Promise((resolveExit) => serverProcess.once("exit", () => resolveExit())); } }; export const closeContextLogs = async (context: E2eContext): Promise => { context.serverProcess.stdout?.unpipe(context.stdoutStream); context.serverProcess.stderr?.unpipe(context.stderrStream); await closeWriteStream(context.stdoutStream); await closeWriteStream(context.stderrStream); }; export const removeE2eDevVars = async (context: E2eContext): Promise => { await rm(context.devVarsPath, { force: true }); }; export const printFailureContext = (context: E2eContext): void => { console.error(`Preserved temp state: ${context.tempDir}`); console.error(`Dev server stdout: ${context.stdoutLogPath}`); console.error(`Dev server stderr: ${context.stderrLogPath}`); console.error(`Cloudflare env vars: ${context.devVarsPath}`); console.error( `Reopen preserved state: CLOUDFLARE_ENV=${context.cloudflareEnv} TESSERA_PERSIST_STATE_PATH=${context.tempDir} npm run dev -- --host 127.0.0.1 --port ${context.port}`, ); };