File
Blob: tests/e2e/harness.ts
| 1 | import { spawn, execFile as execFileCallback, type ChildProcess } from "node:child_process"; |
| 2 | import { createWriteStream, type WriteStream } from "node:fs"; |
| 3 | import { readFile, rm, writeFile } from "node:fs/promises"; |
| 4 | import net from "node:net"; |
| 5 | import { join, resolve } from "node:path"; |
| 6 | import { setTimeout as sleep } from "node:timers/promises"; |
| 7 | import { fileURLToPath } from "node:url"; |
| 8 | import { promisify } from "node:util"; |
| 9 | |
| 10 | import { encodeBase64Url, sha256 } from "./helpers/token"; |
| 11 | |
| 12 | // Pattern is borrowed from sister projects (anvil, bland) โ spin up an |
| 13 | // isolated `vite dev` per E2E run with TESSERA_PERSIST_STATE_PATH pointing |
| 14 | // at a fresh tempdir, write `.dev.vars.{unique-env}` next to the user's |
| 15 | // `.dev.vars`, and pass CLOUDFLARE_ENV={env} so wrangler's bridge loads |
| 16 | // the e2e file *instead of* the user's. The user's working copy is never |
| 17 | // touched, even if the run crashes โ the only side effect is the |
| 18 | // per-run override file, which we delete on teardown. |
| 19 | |
| 20 | const execFile = promisify(execFileCallback); |
| 21 | |
| 22 | const REPO_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); |
| 23 | const NPX_COMMAND = process.platform === "win32" ? "npx.cmd" : "npx"; |
| 24 | const VITE_BIN_PATH = resolve(REPO_ROOT, "node_modules/vite/bin/vite.js"); |
| 25 | const SERVER_READY_TIMEOUT_MS = 90_000; |
| 26 | const POLL_INTERVAL_MS = 500; |
| 27 | const DEV_VARS_PATH = resolve(REPO_ROOT, ".dev.vars"); |
| 28 | const DEV_VARS_EXAMPLE_PATH = resolve(REPO_ROOT, ".dev.vars.example"); |
| 29 | |
| 30 | export interface E2eContext { |
| 31 | tempDir: string; |
| 32 | baseUrl: string; |
| 33 | port: number; |
| 34 | cloudflareEnv: string; |
| 35 | devVarsPath: string; |
| 36 | serverProcess: ChildProcess; |
| 37 | stdoutLogPath: string; |
| 38 | stderrLogPath: string; |
| 39 | stdoutStream: WriteStream; |
| 40 | stderrStream: WriteStream; |
| 41 | } |
| 42 | |
| 43 | const closeWriteStream = async (stream: WriteStream): Promise<void> => |
| 44 | new Promise<void>((resolveClose, reject) => { |
| 45 | if (stream.destroyed || stream.closed || stream.writableFinished) { |
| 46 | resolveClose(); |
| 47 | return; |
| 48 | } |
| 49 | stream.end((error?: Error | null) => (error ? reject(error) : resolveClose())); |
| 50 | }); |
| 51 | |
| 52 | export const getFreePort = async (): Promise<number> => |
| 53 | new Promise<number>((resolvePort, reject) => { |
| 54 | const server = net.createServer(); |
| 55 | server.unref(); |
| 56 | server.once("error", reject); |
| 57 | server.listen(0, "127.0.0.1", () => { |
| 58 | const address = server.address(); |
| 59 | if (!address || typeof address === "string") { |
| 60 | server.close(() => reject(new Error("Failed to allocate a free localhost port."))); |
| 61 | return; |
| 62 | } |
| 63 | server.close((error?: Error | null) => (error ? reject(error) : resolvePort(address.port))); |
| 64 | }); |
| 65 | }); |
| 66 | |
| 67 | const execCommand = async (command: string, args: string[], env: NodeJS.ProcessEnv) => |
| 68 | await execFile(command, args, { cwd: REPO_ROOT, env, maxBuffer: 10 * 1024 * 1024 }); |
| 69 | |
| 70 | const fetchStatus = async (url: string): Promise<number> => { |
| 71 | const res = await fetch(url, { signal: AbortSignal.timeout(3_000) }); |
| 72 | await res.arrayBuffer(); |
| 73 | return res.status; |
| 74 | }; |
| 75 | |
| 76 | const waitForServerReady = async (baseUrl: string): Promise<void> => { |
| 77 | const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; |
| 78 | let lastError: unknown = null; |
| 79 | while (Date.now() < deadline) { |
| 80 | try { |
| 81 | // The asset shell answers 200 once vite finishes its first compile; |
| 82 | // /api/auth/jwks confirms the worker side is wired up too. Both must |
| 83 | // be green before we hand the URL to Playwright. |
| 84 | const root = await fetchStatus(baseUrl); |
| 85 | const jwks = await fetchStatus(`${baseUrl}/api/auth/jwks`); |
| 86 | if (root === 200 && jwks === 200) return; |
| 87 | } catch (e) { |
| 88 | lastError = e; |
| 89 | } |
| 90 | await sleep(POLL_INTERVAL_MS); |
| 91 | } |
| 92 | throw new Error( |
| 93 | `dev server readiness timed out: ${lastError instanceof Error ? lastError.message : String(lastError)}`, |
| 94 | ); |
| 95 | }; |
| 96 | |
| 97 | const loadDevVarsTemplate = async (): Promise<string> => { |
| 98 | for (const path of [DEV_VARS_PATH, DEV_VARS_EXAMPLE_PATH]) { |
| 99 | try { |
| 100 | return await readFile(path, "utf8"); |
| 101 | } catch (error) { |
| 102 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; |
| 103 | } |
| 104 | } |
| 105 | throw new Error("Missing .dev.vars and .dev.vars.example โ cannot seed e2e env"); |
| 106 | }; |
| 107 | |
| 108 | const createE2eDevVarsFile = async (baseUrl: string): Promise<{ cloudflareEnv: string; devVarsPath: string }> => { |
| 109 | // Unique per process so parallel runs (e.g. `playwright test` invoked |
| 110 | // twice) don't stomp each other's e2e env files. |
| 111 | const cloudflareEnv = `e2e-${process.pid}-${Date.now()}`; |
| 112 | const devVarsPath = `${DEV_VARS_PATH}.${cloudflareEnv}`; |
| 113 | const template = await loadDevVarsTemplate(); |
| 114 | // Strip URLs from the template; we re-emit them at the bottom pinned to |
| 115 | // the ephemeral port. Anything else (BETTER_AUTH_SECRET, TURNSTILE_*, |
| 116 | // BOOTSTRAP_ADMIN_EMAIL) carries through as-is. |
| 117 | const stripped = template |
| 118 | .replace(/^BETTER_AUTH_URL=.*$/gm, "") |
| 119 | .replace(/^OIDC_ISSUER=.*$/gm, "") |
| 120 | .trimEnd(); |
| 121 | const content = `${stripped}\nBETTER_AUTH_URL=${baseUrl}\nOIDC_ISSUER=${baseUrl}\n`; |
| 122 | await writeFile(devVarsPath, content); |
| 123 | return { cloudflareEnv, devVarsPath }; |
| 124 | }; |
| 125 | |
| 126 | export const applyMigrations = async (persistTo: string): Promise<void> => { |
| 127 | await execCommand( |
| 128 | NPX_COMMAND, |
| 129 | ["wrangler", "d1", "migrations", "apply", "tessera-prod", "--local", "--persist-to", persistTo], |
| 130 | { ...process.env, CI: "1", NO_D1_WARNING: "true" }, |
| 131 | ); |
| 132 | }; |
| 133 | |
| 134 | export interface SeededInvite { |
| 135 | email: string; |
| 136 | token: string; |
| 137 | password: string; |
| 138 | } |
| 139 | |
| 140 | const sqlString = (s: string) => `'${s.replace(/'/g, "''")}'`; |
| 141 | |
| 142 | export const seedInvite = async (persistTo: string, email: string): Promise<SeededInvite> => { |
| 143 | const tokenBytes = new Uint8Array(32); |
| 144 | crypto.getRandomValues(tokenBytes); |
| 145 | const token = encodeBase64Url(tokenBytes); |
| 146 | const tokenHash = await sha256(token); |
| 147 | const id = crypto.randomUUID(); |
| 148 | const expiresAt = new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(); |
| 149 | |
| 150 | await execCommand( |
| 151 | NPX_COMMAND, |
| 152 | [ |
| 153 | "wrangler", |
| 154 | "d1", |
| 155 | "execute", |
| 156 | "tessera-prod", |
| 157 | "--local", |
| 158 | "--persist-to", |
| 159 | persistTo, |
| 160 | "--command", |
| 161 | `INSERT INTO invites (id, token_hash, email, created_by, expires_at) VALUES (${sqlString(id)}, ${sqlString(tokenHash)}, ${sqlString(email)}, 'e2e-setup', ${sqlString(expiresAt)})`, |
| 162 | ], |
| 163 | { ...process.env, CI: "1", NO_D1_WARNING: "true" }, |
| 164 | ); |
| 165 | |
| 166 | return { email, token, password: "correct-horse-battery-staple" }; |
| 167 | }; |
| 168 | |
| 169 | export const startDevServer = async (persistTo: string): Promise<E2eContext> => { |
| 170 | const port = await getFreePort(); |
| 171 | const baseUrl = `http://127.0.0.1:${port}`; |
| 172 | const stdoutLogPath = join(persistTo, "dev.stdout.log"); |
| 173 | const stderrLogPath = join(persistTo, "dev.stderr.log"); |
| 174 | const stdoutStream = createWriteStream(stdoutLogPath, { flags: "a" }); |
| 175 | const stderrStream = createWriteStream(stderrLogPath, { flags: "a" }); |
| 176 | |
| 177 | const { cloudflareEnv, devVarsPath } = await createE2eDevVarsFile(baseUrl); |
| 178 | |
| 179 | const serverProcess = spawn(process.execPath, [VITE_BIN_PATH, "dev", "--host", "127.0.0.1", "--port", String(port)], { |
| 180 | cwd: REPO_ROOT, |
| 181 | env: { |
| 182 | ...process.env, |
| 183 | TESSERA_PERSIST_STATE_PATH: persistTo, |
| 184 | // wrangler/vite-plugin-cloudflare sees this and loads |
| 185 | // `.dev.vars.{cloudflareEnv}` instead of `.dev.vars`. |
| 186 | CLOUDFLARE_ENV: cloudflareEnv, |
| 187 | }, |
| 188 | stdio: ["ignore", "pipe", "pipe"], |
| 189 | detached: process.platform !== "win32", |
| 190 | }); |
| 191 | |
| 192 | serverProcess.stdout?.pipe(stdoutStream); |
| 193 | serverProcess.stderr?.pipe(stderrStream); |
| 194 | |
| 195 | try { |
| 196 | await Promise.race([ |
| 197 | waitForServerReady(baseUrl), |
| 198 | new Promise<never>((_, reject) => { |
| 199 | serverProcess.once("exit", (code, signal) => { |
| 200 | reject(new Error(`dev server exited before readiness (code=${code}, signal=${signal})`)); |
| 201 | }); |
| 202 | }), |
| 203 | ]); |
| 204 | } catch (error) { |
| 205 | await stopDevServer(serverProcess); |
| 206 | await closeWriteStream(stdoutStream); |
| 207 | await closeWriteStream(stderrStream); |
| 208 | await rm(devVarsPath, { force: true }); |
| 209 | throw error; |
| 210 | } |
| 211 | |
| 212 | return { |
| 213 | tempDir: persistTo, |
| 214 | baseUrl, |
| 215 | port, |
| 216 | cloudflareEnv, |
| 217 | devVarsPath, |
| 218 | serverProcess, |
| 219 | stdoutLogPath, |
| 220 | stderrLogPath, |
| 221 | stdoutStream, |
| 222 | stderrStream, |
| 223 | }; |
| 224 | }; |
| 225 | |
| 226 | export const stopDevServer = async (serverProcess: ChildProcess): Promise<void> => { |
| 227 | if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) return; |
| 228 | |
| 229 | const killServer = (signal: NodeJS.Signals): void => { |
| 230 | if (serverProcess.pid === undefined) return; |
| 231 | if (process.platform === "win32") { |
| 232 | serverProcess.kill(signal); |
| 233 | return; |
| 234 | } |
| 235 | process.kill(-serverProcess.pid, signal); |
| 236 | }; |
| 237 | |
| 238 | killServer("SIGTERM"); |
| 239 | const exited = await Promise.race([ |
| 240 | new Promise<boolean>((resolveExit) => serverProcess.once("exit", () => resolveExit(true))), |
| 241 | sleep(10_000).then(() => false), |
| 242 | ]); |
| 243 | if (!exited && serverProcess.exitCode === null && serverProcess.signalCode === null) { |
| 244 | killServer("SIGKILL"); |
| 245 | await new Promise<void>((resolveExit) => serverProcess.once("exit", () => resolveExit())); |
| 246 | } |
| 247 | }; |
| 248 | |
| 249 | export const closeContextLogs = async (context: E2eContext): Promise<void> => { |
| 250 | context.serverProcess.stdout?.unpipe(context.stdoutStream); |
| 251 | context.serverProcess.stderr?.unpipe(context.stderrStream); |
| 252 | await closeWriteStream(context.stdoutStream); |
| 253 | await closeWriteStream(context.stderrStream); |
| 254 | }; |
| 255 | |
| 256 | export const removeE2eDevVars = async (context: E2eContext): Promise<void> => { |
| 257 | await rm(context.devVarsPath, { force: true }); |
| 258 | }; |
| 259 | |
| 260 | export const printFailureContext = (context: E2eContext): void => { |
| 261 | console.error(`Preserved temp state: ${context.tempDir}`); |
| 262 | console.error(`Dev server stdout: ${context.stdoutLogPath}`); |
| 263 | console.error(`Dev server stderr: ${context.stderrLogPath}`); |
| 264 | console.error(`Cloudflare env vars: ${context.devVarsPath}`); |
| 265 | console.error( |
| 266 | `Reopen preserved state: CLOUDFLARE_ENV=${context.cloudflareEnv} TESSERA_PERSIST_STATE_PATH=${context.tempDir} npm run dev -- --host 127.0.0.1 --port ${context.port}`, |
| 267 | ); |
| 268 | }; |
| 269 |