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 process from "node:process"; |
| 7 | import { setTimeout as sleep } from "node:timers/promises"; |
| 8 | import { promisify } from "node:util"; |
| 9 | import { fileURLToPath } from "node:url"; |
| 10 | |
| 11 | const execFile = promisify(execFileCallback); |
| 12 | |
| 13 | import { |
| 14 | E2E_BASELINE_EMAIL, |
| 15 | E2E_BASELINE_NAME, |
| 16 | E2E_BASELINE_TESSERA_SUB, |
| 17 | TEST_OIDC_CLIENT_ID, |
| 18 | TEST_OIDC_CLIENT_SECRET, |
| 19 | } from "./oidc-mock"; |
| 20 | |
| 21 | const REPO_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url))); |
| 22 | const VITE_BIN_PATH = resolve(REPO_ROOT, "node_modules/vite/bin/vite.js"); |
| 23 | const WRANGLER_BIN_PATH = resolve(REPO_ROOT, "node_modules/wrangler/bin/wrangler.js"); |
| 24 | const EMOJI_GENERATOR_PATH = resolve(REPO_ROOT, "scripts/generate-emoji-data.ts"); |
| 25 | const SERVER_READY_TIMEOUT_MS = 120_000; |
| 26 | const SERVER_START_ATTEMPTS = 5; |
| 27 | const POLL_INTERVAL_MS = 1_000; |
| 28 | const COMMAND_STDIO_MAX_BYTES = 10 * 1024 * 1024; |
| 29 | const COMMAND_TAIL_BYTES = 8_000; |
| 30 | const LOG_TAIL_LINES = 40; |
| 31 | const PROCESS_TERM_TIMEOUT_MS = 10_000; |
| 32 | const PROCESS_KILL_TIMEOUT_MS = 5_000; |
| 33 | const PROCESS_CLOSE_TIMEOUT_MS = 2_000; |
| 34 | const D1_DATABASE = "bland-prod"; |
| 35 | const DEV_VARS_PATH = resolve(REPO_ROOT, ".dev.vars"); |
| 36 | const DEV_VARS_EXAMPLE_PATH = resolve(REPO_ROOT, ".dev.vars.example"); |
| 37 | |
| 38 | const WORKSPACE_NAME = "bland"; |
| 39 | const WORKSPACE_SLUG = "bland"; |
| 40 | |
| 41 | const COMMAND_TIMEOUTS = { |
| 42 | emoji: 60_000, |
| 43 | migrations: 180_000, |
| 44 | seed: 60_000, |
| 45 | } as const; |
| 46 | |
| 47 | export interface E2eContext { |
| 48 | tempDir: string; |
| 49 | baseUrl: string; |
| 50 | port: number; |
| 51 | cloudflareEnv: string; |
| 52 | devVarsPath: string; |
| 53 | serverProcess: ChildProcess; |
| 54 | stdoutLogPath: string; |
| 55 | stderrLogPath: string; |
| 56 | stdoutStream: WriteStream; |
| 57 | stderrStream: WriteStream; |
| 58 | } |
| 59 | |
| 60 | export interface E2eContextFile { |
| 61 | baseUrl: string; |
| 62 | tempDir: string; |
| 63 | oidcIssuer: string; |
| 64 | } |
| 65 | |
| 66 | export const TEST_CREDENTIALS = { |
| 67 | email: E2E_BASELINE_EMAIL, |
| 68 | name: E2E_BASELINE_NAME, |
| 69 | tesseraSub: E2E_BASELINE_TESSERA_SUB, |
| 70 | workspaceSlug: WORKSPACE_SLUG, |
| 71 | } as const; |
| 72 | |
| 73 | function formatElapsed(startedAt: number): string { |
| 74 | return `${((Date.now() - startedAt) / 1000).toFixed(1)}s`; |
| 75 | } |
| 76 | |
| 77 | function stringifyOutput(value: unknown): string { |
| 78 | if (typeof value === "string") return value; |
| 79 | if (Buffer.isBuffer(value)) return value.toString("utf8"); |
| 80 | return ""; |
| 81 | } |
| 82 | |
| 83 | function tailText(value: string, maxBytes = COMMAND_TAIL_BYTES): string { |
| 84 | const trimmed = value.trimEnd(); |
| 85 | if (trimmed.length <= maxBytes) return trimmed; |
| 86 | return `... truncated ...\n${trimmed.slice(-maxBytes)}`; |
| 87 | } |
| 88 | |
| 89 | async function readLogTail(filePath: string, lineCount = LOG_TAIL_LINES): Promise<string> { |
| 90 | try { |
| 91 | const content = await readFile(filePath, "utf8"); |
| 92 | return content.trim().split("\n").slice(-lineCount).join("\n"); |
| 93 | } catch { |
| 94 | return ""; |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | async function formatLogTails(stdoutLogPath: string, stderrLogPath: string): Promise<string> { |
| 99 | const [stdoutTail, stderrTail] = await Promise.all([readLogTail(stdoutLogPath), readLogTail(stderrLogPath)]); |
| 100 | const sections: string[] = []; |
| 101 | if (stdoutTail) sections.push(`Last stdout log lines:\n${stdoutTail}`); |
| 102 | if (stderrTail) sections.push(`Last stderr log lines:\n${stderrTail}`); |
| 103 | return sections.join("\n\n"); |
| 104 | } |
| 105 | |
| 106 | const closeWriteStream = async (stream: WriteStream): Promise<void> => |
| 107 | await new Promise<void>((resolveClose, reject) => { |
| 108 | if (stream.destroyed || stream.closed || stream.writableFinished) { |
| 109 | resolveClose(); |
| 110 | return; |
| 111 | } |
| 112 | try { |
| 113 | stream.end((error?: Error | null) => { |
| 114 | if (error) { |
| 115 | reject(error); |
| 116 | return; |
| 117 | } |
| 118 | resolveClose(); |
| 119 | }); |
| 120 | } catch (error) { |
| 121 | reject(error); |
| 122 | } |
| 123 | }); |
| 124 | |
| 125 | export const getFreePort = async (): Promise<number> => |
| 126 | await new Promise<number>((resolvePort, reject) => { |
| 127 | const server = net.createServer(); |
| 128 | server.unref(); |
| 129 | server.once("error", reject); |
| 130 | server.listen(0, "127.0.0.1", () => { |
| 131 | const address = server.address(); |
| 132 | if (!address || typeof address === "string") { |
| 133 | server.close(() => reject(new Error("Failed to allocate a localhost port."))); |
| 134 | return; |
| 135 | } |
| 136 | server.close((error?: Error | null) => { |
| 137 | if (error) { |
| 138 | reject(error); |
| 139 | return; |
| 140 | } |
| 141 | resolvePort(address.port); |
| 142 | }); |
| 143 | }); |
| 144 | }); |
| 145 | |
| 146 | const execCommand = async ( |
| 147 | label: string, |
| 148 | command: string, |
| 149 | args: string[], |
| 150 | env: NodeJS.ProcessEnv, |
| 151 | timeoutMs: number, |
| 152 | ): Promise<{ stdout: string; stderr: string }> => { |
| 153 | const startedAt = Date.now(); |
| 154 | console.log(`[e2e] ${label} started`); |
| 155 | |
| 156 | try { |
| 157 | const result = await execFile(command, args, { |
| 158 | cwd: REPO_ROOT, |
| 159 | env, |
| 160 | killSignal: "SIGTERM", |
| 161 | maxBuffer: COMMAND_STDIO_MAX_BYTES, |
| 162 | timeout: timeoutMs, |
| 163 | }); |
| 164 | console.log(`[e2e] ${label} completed in ${formatElapsed(startedAt)}`); |
| 165 | return result; |
| 166 | } catch (error) { |
| 167 | const commandError = error as Error & { |
| 168 | code?: number | string | null; |
| 169 | killed?: boolean; |
| 170 | signal?: NodeJS.Signals | null; |
| 171 | stderr?: string | Buffer; |
| 172 | stdout?: string | Buffer; |
| 173 | timedOut?: boolean; |
| 174 | }; |
| 175 | const stdoutTail = tailText(stringifyOutput(commandError.stdout)); |
| 176 | const stderrTail = tailText(stringifyOutput(commandError.stderr)); |
| 177 | const details = [ |
| 178 | `${label} failed after ${formatElapsed(startedAt)}`, |
| 179 | `command: ${command} ${args.join(" ")}`, |
| 180 | `code: ${String(commandError.code ?? "unknown")}`, |
| 181 | `signal: ${String(commandError.signal ?? "none")}`, |
| 182 | `killed: ${String(commandError.killed ?? false)}`, |
| 183 | `message: ${commandError.message}`, |
| 184 | stdoutTail ? `stdout tail:\n${stdoutTail}` : null, |
| 185 | stderrTail ? `stderr tail:\n${stderrTail}` : null, |
| 186 | ].filter(Boolean); |
| 187 | throw new Error(details.join("\n"), { cause: error }); |
| 188 | } |
| 189 | }; |
| 190 | |
| 191 | const execWrangler = async ( |
| 192 | label: string, |
| 193 | args: string[], |
| 194 | persistTo: string, |
| 195 | timeoutMs: number, |
| 196 | ): Promise<{ stdout: string; stderr: string }> => |
| 197 | await execCommand( |
| 198 | label, |
| 199 | process.execPath, |
| 200 | [WRANGLER_BIN_PATH, ...args], |
| 201 | { ...process.env, BLAND_PERSIST_STATE_PATH: persistTo, CI: "1", NO_D1_WARNING: "true" }, |
| 202 | timeoutMs, |
| 203 | ); |
| 204 | |
| 205 | export const generateEmojiData = async (): Promise<void> => { |
| 206 | await execCommand( |
| 207 | "emoji generation", |
| 208 | process.execPath, |
| 209 | ["--import", "tsx", EMOJI_GENERATOR_PATH], |
| 210 | { ...process.env, CI: "1" }, |
| 211 | COMMAND_TIMEOUTS.emoji, |
| 212 | ); |
| 213 | }; |
| 214 | |
| 215 | const waitForServerReady = async ( |
| 216 | baseUrl: string, |
| 217 | logs: { stdoutLogPath: string; stderrLogPath: string }, |
| 218 | ): Promise<void> => { |
| 219 | const startedAt = Date.now(); |
| 220 | const deadline = Date.now() + SERVER_READY_TIMEOUT_MS; |
| 221 | let lastStatus: number | null = null; |
| 222 | let lastError: string | null = null; |
| 223 | |
| 224 | while (Date.now() < deadline) { |
| 225 | try { |
| 226 | const status = await fetch(new URL("/api/v1/health", baseUrl), { |
| 227 | signal: AbortSignal.timeout(5_000), |
| 228 | }).then((r) => r.status); |
| 229 | lastStatus = status; |
| 230 | lastError = null; |
| 231 | if (status === 200) { |
| 232 | console.log(`[e2e] dev server ready in ${formatElapsed(startedAt)}`); |
| 233 | return; |
| 234 | } |
| 235 | } catch (error) { |
| 236 | lastError = error instanceof Error ? error.message : String(error); |
| 237 | } |
| 238 | await sleep(POLL_INTERVAL_MS); |
| 239 | } |
| 240 | |
| 241 | const logTails = await formatLogTails(logs.stdoutLogPath, logs.stderrLogPath); |
| 242 | const details = [ |
| 243 | `dev server readiness timed out after ${formatElapsed(startedAt)}`, |
| 244 | `health URL: ${new URL("/api/v1/health", baseUrl).toString()}`, |
| 245 | `last status: ${lastStatus ?? "none"}`, |
| 246 | `last error: ${lastError ?? "none"}`, |
| 247 | logTails || null, |
| 248 | ].filter(Boolean); |
| 249 | throw new Error(details.join("\n\n")); |
| 250 | }; |
| 251 | |
| 252 | async function withStartupLogTails(error: unknown, logs: { stdoutLogPath: string; stderrLogPath: string }) { |
| 253 | const baseMessage = error instanceof Error ? error.message : String(error); |
| 254 | const logTails = await formatLogTails(logs.stdoutLogPath, logs.stderrLogPath); |
| 255 | return new Error(logTails ? `${baseMessage}\n\n${logTails}` : baseMessage, { cause: error }); |
| 256 | } |
| 257 | |
| 258 | async function waitForProcessExit(serverProcess: ChildProcess, timeoutMs: number): Promise<boolean> { |
| 259 | if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) return true; |
| 260 | |
| 261 | return await new Promise<boolean>((resolveExit) => { |
| 262 | function onExit() { |
| 263 | clearTimeout(timer); |
| 264 | resolveExit(true); |
| 265 | } |
| 266 | |
| 267 | const timer = setTimeout(() => { |
| 268 | serverProcess.off("exit", onExit); |
| 269 | resolveExit(false); |
| 270 | }, timeoutMs); |
| 271 | |
| 272 | serverProcess.once("exit", onExit); |
| 273 | }); |
| 274 | } |
| 275 | |
| 276 | function waitForProcessClose(serverProcess: ChildProcess): Promise<void> { |
| 277 | return new Promise<void>((resolveClose) => { |
| 278 | serverProcess.once("close", () => resolveClose()); |
| 279 | }); |
| 280 | } |
| 281 | |
| 282 | async function waitForProcessCloseWithTimeout(closePromise: Promise<void>, timeoutMs: number): Promise<boolean> { |
| 283 | return await Promise.race([closePromise.then(() => true), sleep(timeoutMs).then(() => false)]); |
| 284 | } |
| 285 | |
| 286 | async function loadDevVarsTemplate(): Promise<string> { |
| 287 | try { |
| 288 | return await readFile(DEV_VARS_EXAMPLE_PATH, "utf8"); |
| 289 | } catch (error) { |
| 290 | if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; |
| 291 | } |
| 292 | |
| 293 | throw new Error("Missing .dev.vars.example"); |
| 294 | } |
| 295 | |
| 296 | async function createE2eDevVarsFile( |
| 297 | baseUrl: string, |
| 298 | oidcIssuer: string, |
| 299 | ): Promise<{ cloudflareEnv: string; devVarsPath: string }> { |
| 300 | const cloudflareEnv = `e2e-${process.pid}-${Date.now()}`; |
| 301 | const devVarsPath = `${DEV_VARS_PATH}.${cloudflareEnv}`; |
| 302 | const template = await loadDevVarsTemplate(); |
| 303 | const allowedOrigins = new URL(baseUrl).origin; |
| 304 | const stripped = template |
| 305 | .replace(/^ALLOWED_ORIGINS=.*$/gm, "") |
| 306 | .replace(/^BLAND_AI_MODE=.*$/gm, "") |
| 307 | .replace(/^TESSERA_OIDC_ISSUER=.*$/gm, "") |
| 308 | .replace(/^TESSERA_OIDC_CLIENT_ID=.*$/gm, "") |
| 309 | .replace(/^TESSERA_OIDC_CLIENT_SECRET=.*$/gm, "") |
| 310 | .trimEnd(); |
| 311 | const content = |
| 312 | `${stripped}\n` + |
| 313 | `ALLOWED_ORIGINS=${allowedOrigins}\n` + |
| 314 | `BLAND_AI_MODE=mock\n` + |
| 315 | `TESSERA_OIDC_ISSUER=${oidcIssuer}\n` + |
| 316 | `TESSERA_OIDC_CLIENT_ID=${TEST_OIDC_CLIENT_ID}\n` + |
| 317 | `TESSERA_OIDC_CLIENT_SECRET=${TEST_OIDC_CLIENT_SECRET}\n`; |
| 318 | |
| 319 | await writeFile(devVarsPath, content); |
| 320 | |
| 321 | return { cloudflareEnv, devVarsPath }; |
| 322 | } |
| 323 | |
| 324 | function escapeSql(value: string): string { |
| 325 | return value.replace(/'/g, "''"); |
| 326 | } |
| 327 | |
| 328 | function generateUlid(): string { |
| 329 | const timestamp = Date.now(); |
| 330 | const encodingChars = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; |
| 331 | let result = ""; |
| 332 | let t = timestamp; |
| 333 | for (let i = 9; i >= 0; i--) { |
| 334 | result = encodingChars[t % 32] + result; |
| 335 | t = Math.floor(t / 32); |
| 336 | } |
| 337 | const random = new Uint8Array(10); |
| 338 | crypto.getRandomValues(random); |
| 339 | for (let i = 0; i < 10; i++) { |
| 340 | result += encodingChars[random[i] % 32]; |
| 341 | } |
| 342 | return result; |
| 343 | } |
| 344 | |
| 345 | export const applyMigrations = async (persistTo: string): Promise<void> => { |
| 346 | await execWrangler( |
| 347 | "D1 migrations", |
| 348 | ["d1", "migrations", "apply", D1_DATABASE, "--local", "--persist-to", persistTo], |
| 349 | persistTo, |
| 350 | COMMAND_TIMEOUTS.migrations, |
| 351 | ); |
| 352 | }; |
| 353 | |
| 354 | export const seedTestUser = async (persistTo: string): Promise<void> => { |
| 355 | const userId = generateUlid(); |
| 356 | const workspaceId = generateUlid(); |
| 357 | const now = new Date().toISOString(); |
| 358 | |
| 359 | const sql = [ |
| 360 | `INSERT INTO users (id, email, name, created_at, updated_at) VALUES ('${escapeSql(userId)}', '${escapeSql(E2E_BASELINE_EMAIL)}', '${escapeSql(E2E_BASELINE_NAME)}', '${escapeSql(now)}', '${escapeSql(now)}');`, |
| 361 | `INSERT INTO tessera_identities (sub, user_id, created_at, last_seen_at) VALUES ('${escapeSql(E2E_BASELINE_TESSERA_SUB)}', '${escapeSql(userId)}', '${escapeSql(now)}', '${escapeSql(now)}');`, |
| 362 | `INSERT INTO workspaces (id, name, slug, owner_id, created_at) VALUES ('${escapeSql(workspaceId)}', '${escapeSql(WORKSPACE_NAME)}', '${escapeSql(WORKSPACE_SLUG)}', '${escapeSql(userId)}', '${escapeSql(now)}');`, |
| 363 | `INSERT INTO memberships (user_id, workspace_id, role, joined_at) VALUES ('${escapeSql(userId)}', '${escapeSql(workspaceId)}', 'owner', '${escapeSql(now)}');`, |
| 364 | ].join("\n"); |
| 365 | |
| 366 | await execWrangler( |
| 367 | "D1 seed user", |
| 368 | ["d1", "execute", D1_DATABASE, "--local", "--persist-to", persistTo, "--yes", "--command", sql], |
| 369 | persistTo, |
| 370 | COMMAND_TIMEOUTS.seed, |
| 371 | ); |
| 372 | }; |
| 373 | |
| 374 | function isPortInUseStartupError(error: unknown): boolean { |
| 375 | const message = error instanceof Error ? error.message : String(error); |
| 376 | return message.includes("is already in use") || message.includes("EADDRINUSE"); |
| 377 | } |
| 378 | |
| 379 | export const startDevServer = async (persistTo: string, oidcIssuer: string): Promise<E2eContext> => { |
| 380 | let lastError: unknown = null; |
| 381 | |
| 382 | for (let attempt = 1; attempt <= SERVER_START_ATTEMPTS; attempt += 1) { |
| 383 | try { |
| 384 | return await startDevServerOnce(persistTo, oidcIssuer, attempt); |
| 385 | } catch (error) { |
| 386 | lastError = error; |
| 387 | if (!isPortInUseStartupError(error) || attempt === SERVER_START_ATTEMPTS) { |
| 388 | throw error; |
| 389 | } |
| 390 | console.warn(`[e2e] dev server port was busy on attempt ${attempt}; retrying with a fresh port`); |
| 391 | await sleep(250); |
| 392 | } |
| 393 | } |
| 394 | |
| 395 | throw lastError instanceof Error ? lastError : new Error(String(lastError)); |
| 396 | }; |
| 397 | |
| 398 | const startDevServerOnce = async (persistTo: string, oidcIssuer: string, attempt: number): Promise<E2eContext> => { |
| 399 | const port = await getFreePort(); |
| 400 | const baseUrl = `http://127.0.0.1:${port}`; |
| 401 | const { cloudflareEnv, devVarsPath } = await createE2eDevVarsFile(baseUrl, oidcIssuer); |
| 402 | const stdoutLogPath = join(persistTo, "dev.stdout.log"); |
| 403 | const stderrLogPath = join(persistTo, "dev.stderr.log"); |
| 404 | const stdoutStream = createWriteStream(stdoutLogPath, { flags: "a" }); |
| 405 | const stderrStream = createWriteStream(stderrLogPath, { flags: "a" }); |
| 406 | |
| 407 | console.log(`[e2e] dev server startup attempt ${attempt}/${SERVER_START_ATTEMPTS} on ${baseUrl}`); |
| 408 | |
| 409 | const serverProcess = spawn( |
| 410 | process.execPath, |
| 411 | [VITE_BIN_PATH, "dev", "--host", "127.0.0.1", "--port", String(port), "--strictPort"], |
| 412 | { |
| 413 | cwd: REPO_ROOT, |
| 414 | env: { ...process.env, BLAND_PERSIST_STATE_PATH: persistTo, CLOUDFLARE_ENV: cloudflareEnv }, |
| 415 | stdio: ["ignore", "pipe", "pipe"], |
| 416 | detached: process.platform !== "win32", |
| 417 | }, |
| 418 | ); |
| 419 | |
| 420 | serverProcess.stdout?.pipe(stdoutStream); |
| 421 | serverProcess.stderr?.pipe(stderrStream); |
| 422 | const closePromise = waitForProcessClose(serverProcess); |
| 423 | |
| 424 | let removeExitListener = () => {}; |
| 425 | try { |
| 426 | const exitedEarly = new Promise<never>((_, reject) => { |
| 427 | const onExit = (code: number | null, signal: NodeJS.Signals | null) => { |
| 428 | reject(new Error(`dev server exited before readiness (code=${code}, signal=${signal})`)); |
| 429 | }; |
| 430 | serverProcess.once("exit", onExit); |
| 431 | removeExitListener = () => serverProcess.off("exit", onExit); |
| 432 | }); |
| 433 | |
| 434 | await Promise.race([waitForServerReady(baseUrl, { stdoutLogPath, stderrLogPath }), exitedEarly]); |
| 435 | removeExitListener(); |
| 436 | } catch (error) { |
| 437 | removeExitListener(); |
| 438 | await stopDevServer(serverProcess); |
| 439 | await waitForProcessCloseWithTimeout(closePromise, PROCESS_CLOSE_TIMEOUT_MS); |
| 440 | serverProcess.stdout?.unpipe(stdoutStream); |
| 441 | serverProcess.stderr?.unpipe(stderrStream); |
| 442 | await closeWriteStream(stdoutStream); |
| 443 | await closeWriteStream(stderrStream); |
| 444 | const startupError = await withStartupLogTails(error, { stdoutLogPath, stderrLogPath }); |
| 445 | await rm(devVarsPath, { force: true }); |
| 446 | throw startupError; |
| 447 | } |
| 448 | |
| 449 | return { |
| 450 | tempDir: persistTo, |
| 451 | baseUrl, |
| 452 | port, |
| 453 | cloudflareEnv, |
| 454 | devVarsPath, |
| 455 | serverProcess, |
| 456 | stdoutLogPath, |
| 457 | stderrLogPath, |
| 458 | stdoutStream, |
| 459 | stderrStream, |
| 460 | }; |
| 461 | }; |
| 462 | |
| 463 | export const stopDevServer = async (serverProcess: ChildProcess): Promise<void> => { |
| 464 | if (serverProcess.exitCode !== null || serverProcess.signalCode !== null) return; |
| 465 | |
| 466 | const killServer = (signal: NodeJS.Signals): boolean => { |
| 467 | if (serverProcess.pid === undefined) return false; |
| 468 | try { |
| 469 | if (process.platform === "win32") { |
| 470 | return serverProcess.kill(signal); |
| 471 | } |
| 472 | process.kill(-serverProcess.pid, signal); |
| 473 | return true; |
| 474 | } catch (error) { |
| 475 | if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; |
| 476 | throw error; |
| 477 | } |
| 478 | }; |
| 479 | |
| 480 | const termWait = waitForProcessExit(serverProcess, PROCESS_TERM_TIMEOUT_MS); |
| 481 | const termSent = killServer("SIGTERM"); |
| 482 | if (!termSent || (await termWait)) return; |
| 483 | |
| 484 | if (serverProcess.exitCode === null && serverProcess.signalCode === null) { |
| 485 | const killWait = waitForProcessExit(serverProcess, PROCESS_KILL_TIMEOUT_MS); |
| 486 | const killSent = killServer("SIGKILL"); |
| 487 | if (killSent && !(await killWait)) { |
| 488 | console.error(`[e2e] dev server did not exit after SIGKILL within ${PROCESS_KILL_TIMEOUT_MS}ms`); |
| 489 | } |
| 490 | } |
| 491 | }; |
| 492 | |
| 493 | export const closeContextLogs = async (ctx: E2eContext): Promise<void> => { |
| 494 | ctx.serverProcess.stdout?.unpipe(ctx.stdoutStream); |
| 495 | ctx.serverProcess.stderr?.unpipe(ctx.stderrStream); |
| 496 | await closeWriteStream(ctx.stdoutStream); |
| 497 | await closeWriteStream(ctx.stderrStream); |
| 498 | }; |
| 499 | |
| 500 | export const printFailureContext = (ctx: E2eContext): void => { |
| 501 | console.error(`Preserved temp state: ${ctx.tempDir}`); |
| 502 | console.error(`Cloudflare env vars: ${ctx.devVarsPath}`); |
| 503 | console.error(`Dev server stdout: ${ctx.stdoutLogPath}`); |
| 504 | console.error(`Dev server stderr: ${ctx.stderrLogPath}`); |
| 505 | console.error( |
| 506 | `Reopen preserved state: CLOUDFLARE_ENV=${ctx.cloudflareEnv} BLAND_PERSIST_STATE_PATH=${ctx.tempDir} node ${VITE_BIN_PATH} dev --host 127.0.0.1 --port ${ctx.port} --strictPort`, |
| 507 | ); |
| 508 | }; |
| 509 | |
| 510 | export const printLogTails = async (ctx: E2eContext): Promise<void> => { |
| 511 | for (const [label, filePath] of [ |
| 512 | ["stdout", ctx.stdoutLogPath], |
| 513 | ["stderr", ctx.stderrLogPath], |
| 514 | ] as const) { |
| 515 | const tail = await readLogTail(filePath); |
| 516 | if (tail.length > 0) { |
| 517 | console.error(`Last ${label} log lines:\n${tail}`); |
| 518 | } |
| 519 | } |
| 520 | }; |