121 lines · 5.4 KB · TypeScript
RawDownload
// Per-scenario service emulators, spawned IN THIS PROCESS.//// These used to be hosted instances created through// `https://<service>.emulators.dev/_emulate/instances`. That put the public// internet on the critical path of a scenario that is otherwise entirely// local, and it showed: in the two weeks to 2026-08-20, `connect ETIMEDOUT`// and bare 502s reaching the edge failed 17 shards. Worse, by 2026-08-20// `graphql-introspection-health` failed on every CI run because the hosted// GitHub emulator answered `403 API rate limit exceeded` — GitHub's real// unauthenticated-rate-limit shape — instead of the 401 the scenario had// armed a fault for. Raising the fault budget from 10 to 100 changed nothing,// so whatever produced that 403 sat outside the instance's own fault// accounting; from a shared CI egress IP there is no version of the scenario// that can stay under it.//// `@executor-js/emulate` describes itself as "local drop-in replacement// services for CI and no-network sandboxes", and the suite already boots// WorkOS and Autumn this way in `setup/cloud.boot.ts`. This is the same thing// per scenario: same package, same wire behaviour, same per-run isolation (a// fresh process-local instance, so ledger assertions stay clean), minus the// network. The app under test reaches it over loopback, which cloud already// allows for the WorkOS and Autumn emulators (`ALLOW_LOCAL_NETWORK`).import { createServer } from "node:net"; import { Effect, Schedule, type Scope } from "effect"; import { createEmulator, type ServiceName } from "@executor-js/emulate"; /** The emulator could not be started, or never answered its control plane. */export class EmulatorInstanceError extends Error {  readonly _tag = "EmulatorInstanceError";   constructor(    readonly service: string,    readonly reason: string,  ) {    super(`${service} emulator did not start: ${reason}`);    this.name = "EmulatorInstanceError";  }} // Ask the OS for a port and hand it straight to the emulator. There is a// window between the probe closing and the emulator binding, which is why// `spawnEmulator` retries: on Linux CI this whole range is ephemeral, so an// outbound socket really can take the port in between (the same race// `src/ports.ts` documents for the target's own stack).const freePort = (): Promise<number | null> =>  new Promise((resolve) => {    const probe = createServer();    probe.once("error", () => resolve(null));    probe.listen(0, "0.0.0.0", () => {      const address = probe.address();      const port = typeof address === "object" && address !== null ? address.port : 0;      probe.close(() => resolve(port === 0 ? null : port));    });  }); const READY_TIMEOUT = "10 seconds"; const spawnEmulator = (service: ServiceName, label: string) =>  Effect.gen(function* () {    const port = yield* Effect.promise(freePort).pipe(      Effect.flatMap((found) =>        found === null          ? Effect.fail(new EmulatorInstanceError(service, "the OS offered no free port"))          : Effect.succeed(found),      ),    );    // 127.0.0.1, not localhost: the emulator stamps this base URL into its own    // OAuth metadata and discovery documents, and `localhost` resolves to ::1    // first on some hosts. Pinning the family keeps what the app is told    // byte-identical to what the emulator is listening on.    const baseUrl = `http://127.0.0.1:${port}`;    const emulator = yield* Effect.tryPromise({      try: () => createEmulator({ service, port, baseUrl }),      catch: (cause) => new EmulatorInstanceError(service, String(cause)),    });    // `createEmulator` returns as soon as the server is handed off, so prove    // the control plane answers before a scenario arms a fault against it.    yield* Effect.tryPromise({      try: async () => {        const response = await fetch(`${baseUrl}/_emulate/manifest`);        if (!response.ok) throw new Error(`manifest HTTP ${response.status}`);      },      catch: (cause) => new EmulatorInstanceError(service, String(cause)),    }).pipe(      Effect.retry(Schedule.both(Schedule.spaced("100 millis"), Schedule.recurs(50))),      Effect.timeoutOrElse({        duration: READY_TIMEOUT,        orElse: () =>          Effect.fail(            new EmulatorInstanceError(service, `control plane silent for ${READY_TIMEOUT}`),          ),      }),      // The half-started server must not outlive the failure.      Effect.tapError(() => Effect.promise(() => emulator.close())),    );    yield* Effect.logDebug(`[e2e] ${label} ${service} emulator at ${baseUrl}`);    return emulator;  }); /** * Start a `service` emulator for the calling scenario and return its base URL. * * Scoped: the emulator is closed when the scenario's scope closes, so wrap the * body in `Effect.scoped`. `label` names the instance in debug output. */export const createEmulatorInstance = (  service: ServiceName,  label = "e2e",): Effect.Effect<string, never, Scope.Scope> =>  Effect.acquireRelease(    spawnEmulator(service, label).pipe(      // A port lost between probe and bind is worth one more try; anything      // else is a defect in the run, not a product failure the scenario      // should be asked to model.      Effect.retry(Schedule.both(Schedule.spaced("200 millis"), Schedule.recurs(2))),      Effect.orDie,    ),    (emulator) => Effect.promise(() => emulator.close()),  ).pipe(Effect.map((emulator) => emulator.url));