/** * stripe-mock lifecycle helpers for local and CI tests. * * The normal test harness starts one stripe-mock process per run on a free * local port, then passes that port to every spawned test process. Direct * `deno test` runs can still use the default port or an explicit * STRIPE_MOCK_PORT. */ import { join } from "node:path"; import { errorMessage } from "#shared/error-message.ts"; import { delay } from "#shared/now.ts"; import { streamChunks } from "#shared/stream-chunks.ts"; import { stripeMock } from "#shared/stripe/mock.ts"; import { withFileLock } from "./lock-file.ts"; import { stopProcess, stopProcessNow } from "./process.ts"; import { binDirGuard, defaultStripeMockPaths, downloadStripeMock, type StripeMockCommands, type StripeMockInstallOptions, type StripeMockPaths, } from "./stripe-mock/install.ts"; const STRIPE_MOCK_HOST = "localhost"; export const STRIPE_MOCK_FAILED_TO_START = "stripe-mock failed to start"; const START_CONFIRM_DELAY_MS = 50; /** How long to keep looking for a mock that has not opened its port yet. It * takes most of a second to load its API spec, so the wait has to be generous. */ const START_BUDGET_MS = 10_000; /** How often to look while waiting. Only the budget above bounds the wait, so * a finer poll just spends less of it asleep on a mock that is already up. */ const START_POLL_DELAY_MS = 10; const START_ATTEMPTS = 5; const STOP_TIMEOUT_MS = 2_000; const START_LOCK_NAME = "stripe-mock.start.lock"; type StripeMockEnvSource = { get: (key: string) => string | undefined; }; export const stripeMockPortFromEnv = ( env: StripeMockEnvSource = Deno.env, ): number => { const port = env.get("STRIPE_MOCK_PORT"); return stripeMock.port(port); }; type StripeMockEnv = { NO_PROXY: string; no_proxy: string; STRIPE_MOCK_HOST: string; STRIPE_MOCK_PORT: string; }; export const stripeMockEnv = ( port = stripeMockPortFromEnv(), ): StripeMockEnv => ({ NO_PROXY: "localhost,127.0.0.1,::1", no_proxy: "localhost,127.0.0.1,::1", STRIPE_MOCK_HOST, STRIPE_MOCK_PORT: String(port), }); export interface PortReservation { port: number; release: () => void; } /** Hold a free localhost port until its future owner is ready to bind it. */ export const reserveAvailablePort = (): PortReservation => { const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); const { port } = listener.addr as Deno.NetAddr; let held = true; return { port, release: () => { if (held) listener.close(); held = false; }, }; }; /** Ask the OS for a currently free localhost port. */ export const findAvailablePort = (): number => { const reservation = reserveAvailablePort(); reservation.release(); return reservation.port; }; const chooseStripeMockPort = (env: StripeMockEnvSource): number => env.get("STRIPE_MOCK_PORT") ? stripeMockPortFromEnv(env) : findAvailablePort(); /** Check whether a process is listening on the stripe-mock port. */ const isStripeMockRunning = async ( port = stripeMockPortFromEnv(), ): Promise => { try { const conn = await Deno.connect({ hostname: "127.0.0.1", port }); conn.close(); return true; } catch { return false; } }; const raceWithDelay = async ( first: Promise, delayMs: number, delayedValue: () => T, ): Promise => { let timeout: number | undefined; const delayed = new Promise((resolve) => { timeout = setTimeout(() => resolve(delayedValue()), delayMs); }); try { return await Promise.race([first, delayed]); } finally { clearTimeout(timeout); } }; const confirmProcessStillRunning = ( processExited: Promise, delayMs: number, isExited: () => boolean, ): Promise => raceWithDelay( processExited.then(() => false), delayMs, () => !isExited(), ); const waitForOwnedStripeMock = async ( process: Deno.ChildProcess, port: number, budgetMs: number, delayMs: number, confirmDelayMs: number, ): Promise => { let exited = false; const processExited = process.status.then(() => { exited = true; }); // Counted on the clock that only goes forwards, so a system clock correction // mid-start cannot cut the wait short or stretch it. const giveUpAt = performance.now() + budgetMs; while (performance.now() < giveUpAt) { if (exited) return false; if (await isStripeMockRunning(port)) { return await confirmProcessStillRunning( processExited, confirmDelayMs, () => exited, ); } await delay(delayMs); } return false; }; type RunningStripeMock = { port: number; stop: () => Promise; stopNow: () => void; }; type SpawnedStripeMock = { closeStderr: () => Promise; process: Deno.ChildProcess; stderr: () => Promise; }; type CapturedStreamText = { close: () => Promise; read: () => Promise; }; type StartStripeMockOptions = StripeMockInstallOptions & { commands?: Partial; choosePort?: (env: StripeMockEnvSource) => number; confirmDelayMs?: number; delayMs?: number; env?: StripeMockEnvSource; budgetMs?: number; paths?: StripeMockPaths; port?: number; startAttempts?: number; stopTimeoutMs?: number; }; const alreadyRunningStripeMock = (port: number): RunningStripeMock => ({ port, stop: () => Promise.resolve(), stopNow: () => {}, }); const managedStripeMock = ( port: number, process: Deno.ChildProcess, stopTimeoutMs: number, closeStderr: () => Promise, ): RunningStripeMock => { process.unref(); return { port, stop: () => stopProcess(process, stopTimeoutMs, closeStderr), stopNow: () => stopProcessNow(process), }; }; const hasConfiguredPort = ( options: StartStripeMockOptions, env: StripeMockEnvSource, ): boolean => options.port !== undefined || env.get("STRIPE_MOCK_PORT") !== undefined; const spawnStripeMock = ( paths: StripeMockPaths, port: number, ): SpawnedStripeMock => { const process = new Deno.Command(paths.binaryPath, { args: ["-http-port", String(port)], stderr: "piped", stdout: "null", }).spawn(); const stderr = captureStreamText(process.stderr); return { closeStderr: stderr.close, process, stderr: stderr.read }; }; const captureStreamText = ( stream: ReadableStream, ): CapturedStreamText => ({ close: () => stream.cancel(), read: async () => { const decoder = new TextDecoder(); let text = ""; for await (const chunk of streamChunks(stream)) { text += decoder.decode(chunk, { stream: true }); } return (text + decoder.decode()).trim(); }, }); /** Read a stopped mock's stderr with a bound. A process the kill never * reached can hold the stderr pipe open, so the retry gives up on the text * instead of waiting on that pipe forever (issue #2225). */ const stoppedMockStderr = async ( spawned: SpawnedStripeMock, timeoutMs: number, ): Promise => { const read = spawned.stderr(); let timer = 0; const giveUp = new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); }); const text = await Promise.race([read, giveUp]); clearTimeout(timer); if (text !== null) return text; // The pipe closes when the process holding it dies; forcing it closed is // not possible while the given-up read still holds the stream. return `stripe-mock stderr read timed out after ${timeoutMs}ms`; }; const stripeMockStartLockPath = (paths: StripeMockPaths): string => join(paths.binDir, START_LOCK_NAME); const withStripeMockStartLock = binDirGuard( (paths) => (body) => withFileLock(stripeMockStartLockPath(paths), body), ); const resolveStripeMockPort = ( options: StartStripeMockOptions, env: StripeMockEnvSource, ): number => { const { choosePort = chooseStripeMockPort } = options; // Only a missing port is replaced, so an explicit one is always honoured. const { port = choosePort(env) } = options; return port; }; const confirmOwnedStripeMock = ( options: StartStripeMockOptions, port: number, spawned: SpawnedStripeMock, ): Promise => { const { budgetMs = START_BUDGET_MS, confirmDelayMs = START_CONFIRM_DELAY_MS, delayMs = START_POLL_DELAY_MS, } = options; return waitForOwnedStripeMock( spawned.process, port, budgetMs, delayMs, confirmDelayMs, ); }; type StripeMockStartAttempt = | { readonly kind: "running"; readonly mock: RunningStripeMock } | { readonly kind: "retry"; readonly error: string }; /** The values every start step threads through together: the caller's options, * the env to read the port from, the resolved paths, and whether the port was * pinned (env/option) rather than auto-picked. */ type StripeMockStartContext = { options: StartStripeMockOptions; env: StripeMockEnvSource; paths: StripeMockPaths; configuredPort: boolean; }; const attemptStartStripeMock = async ( ctx: StripeMockStartContext, ): Promise => { const { options, env, paths, configuredPort } = ctx; const port = resolveStripeMockPort(options, env); if (await isStripeMockRunning(port)) { return configuredPort ? { kind: "running", mock: alreadyRunningStripeMock(port) } : { error: "", kind: "retry" }; } if (configuredPort) await downloadStripeMock(options); // A spawn the machine refuses counts as a failed try like any other, so it // retries and the thrown error names the refusal (a raw "Text file busy" // under load used to escape unwrapped). let spawned: SpawnedStripeMock; try { spawned = spawnStripeMock(paths, port); } catch (error) { return { error: errorMessage(error), kind: "retry" }; } const { stopTimeoutMs = STOP_TIMEOUT_MS } = options; if (await confirmOwnedStripeMock(options, port, spawned)) { return { kind: "running", mock: managedStripeMock( port, spawned.process, stopTimeoutMs, spawned.closeStderr, ), }; } await stopProcess(spawned.process, stopTimeoutMs); return { error: await stoppedMockStderr(spawned, stopTimeoutMs), kind: "retry", }; }; const startStripeMockProcess = async ( ctx: StripeMockStartContext, startAttempts: number, ): Promise => { let lastStartupError = ""; for (let attempt = 0; attempt < startAttempts; attempt++) { const result = await attemptStartStripeMock(ctx); if (result.kind === "running") return result.mock; // Keep the first meaningful stderr: a later retry can return an empty error // that would otherwise clobber the useful failure context. if (result.error) lastStartupError = result.error; } throw new Error( lastStartupError ? `${STRIPE_MOCK_FAILED_TO_START}: ${lastStartupError}` : STRIPE_MOCK_FAILED_TO_START, ); }; export const startStripeMock = async ( options: StartStripeMockOptions = {}, ): Promise => { const { env = Deno.env, paths = defaultStripeMockPaths, startAttempts = START_ATTEMPTS, } = options; const configuredPort = hasConfiguredPort(options, env); if (!configuredPort) await downloadStripeMock(options); const ctx: StripeMockStartContext = { configuredPort, env, options, paths }; // A pinned port belongs to one mock only, so retrying it cannot help. const start = () => startStripeMockProcess(ctx, configuredPort ? 1 : startAttempts); return configuredPort ? await start() : await withStripeMockStartLock(paths, start); };