Ctrl K
ComunidadGuías…

scripts/stripe-mock.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * 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<boolean> => {
  try {
    const conn = await Deno.connect({ hostname: "127.0.0.1", port });
    conn.close();
    return true;
  } catch {
    return false;
  }
};

const raceWithDelay = async <T>(
  first: Promise<T>,
  delayMs: number,
  delayedValue: () => T,
): Promise<T> => {
  let timeout: number | undefined;
  const delayed = new Promise<T>((resolve) => {
    timeout = setTimeout(() => resolve(delayedValue()), delayMs);
  });
  try {
    return await Promise.race([first, delayed]);
  } finally {
    clearTimeout(timeout);
  }
};

const confirmProcessStillRunning = (
  processExited: Promise<void>,
  delayMs: number,
  isExited: () => boolean,
): Promise<boolean> =>
  raceWithDelay(
    processExited.then(() => false),
    delayMs,
    () => !isExited(),
  );

const waitForOwnedStripeMock = async (
  process: Deno.ChildProcess,
  port: number,
  budgetMs: number,
  delayMs: number,
  confirmDelayMs: number,
): Promise<boolean> => {
  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<void>;
  stopNow: () => void;
};

type SpawnedStripeMock = {
  closeStderr: () => Promise<void>;
  process: Deno.ChildProcess;
  stderr: () => Promise<string>;
};

type CapturedStreamText = {
  close: () => Promise<void>;
  read: () => Promise<string>;
};

type StartStripeMockOptions = StripeMockInstallOptions & {
  commands?: Partial<StripeMockCommands>;
  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<void>,
): 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<Uint8Array>,
): 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<string> => {
  const read = spawned.stderr();
  let timer = 0;
  const giveUp = new Promise<null>((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<boolean> => {
  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<StripeMockStartAttempt> => {
  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<RunningStripeMock> => {
  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<RunningStripeMock> => {
  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);
};