From 1db787fca9ff527efe5982e4be10d59d20e6ce18 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:46:45 -0700 Subject: [PATCH 1/3] Add one-shot fault injection to the emulator control plane Arm faults via POST /_emulate/faults (match on operationId, method, or a path glob; response status/body/headers; times + optional delayMs). Matching requests short-circuit with the fault response, decrement the remaining count, and disappear at zero. Faulted requests still land in the request ledger marked faulted with the fault id, so tests can prove a fault fired. Typed surface: client.faults.arm/list/clear on both createEmulator and connectEmulator. reset() clears armed faults. --- README.md | 29 ++- apps/web/app/docs/architecture/page.mdx | 3 +- apps/web/app/docs/ledger/page.mdx | 8 + apps/web/app/docs/page.mdx | 24 ++ apps/web/app/docs/programmatic-api/page.mdx | 18 ++ .../core/src/__tests__/client.test.ts | 20 ++ .../core/src/__tests__/control-plane.test.ts | 84 ++++++ packages/@emulators/core/src/client.ts | 15 ++ packages/@emulators/core/src/control-plane.ts | 32 ++- packages/@emulators/core/src/faults.ts | 240 ++++++++++++++++++ packages/@emulators/core/src/index.ts | 9 + packages/@emulators/core/src/ledger.ts | 6 + .../@emulators/core/src/middleware/auth.ts | 2 + packages/@emulators/core/src/server.ts | 6 +- packages/emulate/src/__tests__/api.test.ts | 55 ++++ packages/emulate/src/api.ts | 7 +- packages/emulate/src/index.ts | 4 +- skills/apple/SKILL.md | 2 +- skills/autumn/SKILL.md | 2 +- skills/aws/SKILL.md | 2 +- skills/clerk/SKILL.md | 2 +- skills/emulate/SKILL.md | 9 + skills/github/SKILL.md | 2 +- skills/google/SKILL.md | 2 +- skills/microsoft/SKILL.md | 2 +- skills/mongoatlas/SKILL.md | 2 +- skills/next/SKILL.md | 2 +- skills/okta/SKILL.md | 2 +- skills/posthog/SKILL.md | 2 +- skills/resend/SKILL.md | 2 +- skills/slack/SKILL.md | 2 +- skills/spotify/SKILL.md | 2 +- skills/stripe/SKILL.md | 2 +- skills/vercel/SKILL.md | 2 +- skills/workos/SKILL.md | 2 +- 35 files changed, 576 insertions(+), 29 deletions(-) create mode 100644 packages/@emulators/core/src/faults.ts diff --git a/README.md b/README.md index d58babbc..38ab1e3f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,10 @@ Every running service also exposes a public control plane under `/_emulate`: | `GET /_emulate/mcp` | Return the MCP endpoint when the service exposes one | | `GET /_emulate/ledger` | Recent API calls with sensitive fields redacted | | `DELETE /_emulate/ledger` | Clear the request ledger | +| `POST /_emulate/faults` | Arm a one-shot or counted fault against matching provider requests | +| `GET /_emulate/faults` | List armed faults with remaining counts | +| `DELETE /_emulate/faults` | Clear all armed faults | +| `DELETE /_emulate/faults/:id` | Clear one armed fault | | `GET /_emulate/logs` | Webhook deliveries plus recent requests | | `GET /_emulate/state` | Current emulator store snapshot | | `POST /_emulate/reset` | Reset state, webhooks, and request logs, then replay seed data | @@ -60,7 +64,17 @@ The manifest is the machine-readable single source of truth for a service. Each ### Request ledger -The request ledger is a core feature, not a debug afterthought. Each entry records a correlation id (honored from `X-Correlation-Id` or `X-Request-Id`, echoed back in the `X-Correlation-Id` response header, otherwise generated), the matched route and operation id, method, host, path, query, sanitized request headers and body, the authenticated identity, the response status with a one-line summary, recorded side effects, webhook deliveries, and the request duration. On the hosted Cloudflare surface the ledger is persisted across Durable Object eviction, so it survives instance restarts. +The request ledger is a core feature, not a debug afterthought. Each entry records a correlation id (honored from `X-Correlation-Id` or `X-Request-Id`, echoed back in the `X-Correlation-Id` response header, otherwise generated), the matched route and operation id, method, host, path, query, sanitized request headers and body, the authenticated identity, the response status with a one-line summary, recorded side effects, webhook deliveries, fault markers, and the request duration. On the hosted Cloudflare surface the ledger is persisted across Durable Object eviction, so it survives instance restarts. + +### Fault injection + +Tests and development harnesses can arm one-shot faults with `POST /_emulate/faults`. A fault matches all provided criteria: `operationId` when the emulator can resolve one from its service manifest, `method`, and `pathPattern`. `pathPattern` uses a simple glob where `*` matches any characters in the request path. Matching requests short-circuit with the configured response, decrement `remaining`, and are removed at zero. Faulted requests are still written to `/_emulate/ledger` with `faulted: true` and `faultId`. + +```bash +curl -s -X POST "$EMULATOR_URL/_emulate/faults" \ + -H "content-type: application/json" \ + -d '{"match":{"method":"GET","pathPattern":"/v1/*"},"response":{"status":503,"body":{"error":"temporary"}}}' +``` Credential creation follows each service's real shape. For example, GitHub can mint a bearer token for a user, Spotify creates a client credentials app, Google/Microsoft/Apple/Okta/Clerk create OAuth/OIDC clients, Stripe and Resend create API-key style credentials, and AWS advertises provider-specific SDK credentials instead of pretending to be OAuth. @@ -237,11 +251,14 @@ afterAll(() => Promise.all([github.close(), vercel.close()])); ### Instance methods -| Method | Description | -| --------- | -------------------------------------------- | -| `url` | Base URL of the running server | -| `reset()` | Wipe the store and replay seed data | -| `close()` | Shut down the HTTP server, returns a Promise | +| Method | Description | +| ----------------- | -------------------------------------------- | +| `url` | Base URL of the running server | +| `reset()` | Wipe the store and replay seed data | +| `close()` | Shut down the HTTP server, returns a Promise | +| `faults.arm()` | Arm a one-shot or counted fault | +| `faults.list()` | List armed faults with remaining counts | +| `faults.clear()` | Clear one fault by id, or all faults | ## Configuration diff --git a/apps/web/app/docs/architecture/page.mdx b/apps/web/app/docs/architecture/page.mdx index a380f95d..32b4bff7 100644 --- a/apps/web/app/docs/architecture/page.mdx +++ b/apps/web/app/docs/architecture/page.mdx @@ -59,9 +59,10 @@ The core server registers `/_emulate` routes for every service. These routes are - `/_emulate/seed` adds runtime seed data using the service seed schema. - `/_emulate/instances` returns URLs for hosted instances, which deployments create lazily when first used. - `/_emulate/ledger` returns recent request and response records with auth headers, tokens, client secrets, API keys, and similar fields redacted. See the [Request Ledger](/docs/ledger). +- `/_emulate/faults` arms, lists, and clears one-shot or counted fault responses for matching provider requests. - `/_emulate/logs` returns webhook deliveries plus the most recent provider requests. - `/_emulate/state` returns the current store snapshot. -- `/_emulate/reset` resets state, webhooks, and the request ledger, then replays seed data. +- `/_emulate/reset` resets state, webhooks, faults, and the request ledger, then replays seed data. - `/_emulate/services` returns a machine-readable catalog of every hosted service and is available from any host, including the apex. Service manifests are hand-authored metadata derived from the real service. OpenAPI, GraphQL, MCP, OAuth metadata, and provider discovery documents are useful inputs, but emulate only exposes the protocols and auth modes that fit each service. diff --git a/apps/web/app/docs/ledger/page.mdx b/apps/web/app/docs/ledger/page.mdx index 7ccb1b9f..2fe74c19 100644 --- a/apps/web/app/docs/ledger/page.mdx +++ b/apps/web/app/docs/ledger/page.mdx @@ -49,6 +49,10 @@ Each ledger entry records: operationId Provider operation id when the handler advertises one + + faulted, faultId + Set when a response was injected by /_emulate/faults, so tests can prove the fault fired + request Sanitized request headers and body, with bodyTruncated set when the body was clipped @@ -88,6 +92,10 @@ Sensitive headers such as `authorization`, `cookie`, `set-cookie`, `x-api-key`, Set `X-Correlation-Id` (or `X-Request-Id`) on a request and the emulator stores it on the ledger entry and echoes it back on the `X-Correlation-Id` response header, so you can stitch an entry to a specific call in your test. When no id is supplied the emulator generates one. +## Faulted requests + +`POST /_emulate/faults` arms a one-shot or counted response for matching provider requests. Match criteria are combined: `operationId` when the emulator can resolve one from the service manifest, `method`, and `pathPattern`. `pathPattern` is a glob where `*` matches any characters in the request path. A matched request short-circuits with the configured response, decrements the fault's remaining count, and is recorded here with `faulted: true` and `faultId`. + ## Persistence On the hosted Cloudflare surface the ledger is persistent. Each instance is backed by a Durable Object that snapshots state and the ledger to storage, so records survive eviction. Locally and in process the ledger is in memory and is cleared on reset. See [Deployment](/docs/deployment) for the instance model. diff --git a/apps/web/app/docs/page.mdx b/apps/web/app/docs/page.mdx index ffc62f9d..67b6a456 100644 --- a/apps/web/app/docs/page.mdx +++ b/apps/web/app/docs/page.mdx @@ -114,6 +114,30 @@ Every service exposes a public control plane under `/_emulate`. Clear the request ledger + + + POST /_emulate/faults + + Arm a one-shot or counted fault against matching provider requests + + + + GET /_emulate/faults + + List armed faults with remaining counts + + + + DELETE /_emulate/faults + + Clear all armed faults + + + + DELETE /_emulate/faults/:id + + Clear one armed fault + GET /_emulate/logs diff --git a/apps/web/app/docs/programmatic-api/page.mdx b/apps/web/app/docs/programmatic-api/page.mdx index fee46bf9..93c1e94b 100644 --- a/apps/web/app/docs/programmatic-api/page.mdx +++ b/apps/web/app/docs/programmatic-api/page.mdx @@ -132,6 +132,24 @@ afterAll(() => Promise.all([github.close(), vercel.close()])); Shut down the HTTP server, returns a Promise + + + faults.arm(input) + + Arm a one-shot or counted fault against matching provider requests + + + + faults.list() + + List armed faults with remaining counts + + + + faults.clear(id?) + + Clear one armed fault, or all faults when no id is given + diff --git a/packages/@emulators/core/src/__tests__/client.test.ts b/packages/@emulators/core/src/__tests__/client.test.ts index f94b20eb..951feb66 100644 --- a/packages/@emulators/core/src/__tests__/client.test.ts +++ b/packages/@emulators/core/src/__tests__/client.test.ts @@ -78,6 +78,26 @@ describe("EmulatorClient", () => { expect(await client.ledger.list()).toHaveLength(0); }); + it("arms, lists, and clears faults with typed entries", async () => { + const { client } = makeClient(); + + const fault = await client.faults.arm({ + match: { method: "GET", pathPattern: "/things" }, + response: { status: 503, body: { error: "planned" } }, + }); + expect(fault.id).toMatch(/^fault_/); + expect(fault.remaining).toBe(1); + + expect((await client.faults.list()).map((f) => f.id)).toEqual([fault.id]); + + await client.faults.clear(fault.id); + expect(await client.faults.list()).toHaveLength(0); + + await client.faults.arm({ match: { method: "GET", pathPattern: "/things" }, response: { status: 429 } }); + await client.faults.clear(); + expect(await client.faults.list()).toHaveLength(0); + }); + it("mints credentials in the service's shape", async () => { const { client } = makeClient(); const credential = await client.credentials.mint({ type: "bearer-token", login: "tester" }); diff --git a/packages/@emulators/core/src/__tests__/control-plane.test.ts b/packages/@emulators/core/src/__tests__/control-plane.test.ts index 9d8ac07c..1398e0c9 100644 --- a/packages/@emulators/core/src/__tests__/control-plane.test.ts +++ b/packages/@emulators/core/src/__tests__/control-plane.test.ts @@ -49,6 +49,7 @@ describe("control plane", () => { const quickstart = await (await app.request("/_emulate/quickstart")).text(); expect(quickstart).toContain("Provider base URL: https://demo.instance.emulators.dev"); + expect(quickstart).toContain("/_emulate/faults"); const state = (await (await app.request("/_emulate/state")).json()) as { collections: Record }; expect(state.collections["demo.things"]).toBeDefined(); @@ -93,6 +94,82 @@ describe("control plane", () => { expect(ledger.entries[0]!.identity.user?.login).toBe("admin"); }); + it("arms, lists, clears, and records one-shot faults", async () => { + const { app, store } = createServer(plugin, { + baseUrl: "https://demo.instance.emulators.dev", + manifest: { + id: "demo", + name: "Demo", + description: "Demo emulator.", + surfaces: [{ id: "rest", kind: "rest", title: "REST API", status: "partial", basePath: "/" }], + auth: [{ id: "bearer", title: "Bearer token", type: "bearer-token", status: "partial" }], + specs: [ + { + kind: "manual", + title: "Manual behavior", + coverage: "partial", + operations: [{ operationId: "listThings", method: "GET", path: "/things", status: "hand-authored" }], + }, + ], + }, + }); + plugin.seed?.(store, "https://demo.instance.emulators.dev"); + + const armRes = await app.request("/_emulate/faults", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + match: { operationId: "listThings" }, + response: { status: 503, body: { error: "planned" } }, + }), + }); + expect(armRes.status).toBe(200); + const { fault } = (await armRes.json()) as { fault: { id: string; remaining: number } }; + expect(fault.remaining).toBe(1); + + let faults = (await (await app.request("/_emulate/faults")).json()) as { faults: Array<{ id: string }> }; + expect(faults.faults.map((f) => f.id)).toContain(fault.id); + + const faulted = await app.request("/things"); + expect(faulted.status).toBe(503); + expect(await faulted.json()).toEqual({ error: "planned" }); + + const ledger = (await (await app.request("/_emulate/ledger")).json()) as { + entries: Array<{ path: string; response: { status: number }; faulted?: boolean; faultId?: string }>; + }; + expect(ledger.entries[0]).toMatchObject({ + path: "/things", + response: { status: 503 }, + faulted: true, + faultId: fault.id, + }); + + const normal = await app.request("/things"); + expect(normal.status).toBe(200); + expect(((await normal.json()) as { things: Thing[] }).things).toHaveLength(1); + + faults = (await (await app.request("/_emulate/faults")).json()) as { faults: Array<{ id: string }> }; + expect(faults.faults).toHaveLength(0); + + const pathFaultRes = await app.request("/_emulate/faults", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ match: { method: "GET", pathPattern: "/things" }, response: { status: 429 } }), + }); + const pathFault = (await pathFaultRes.json()) as { fault: { id: string } }; + const clearOne = await app.request(`/_emulate/faults/${pathFault.fault.id}`, { method: "DELETE" }); + expect(clearOne.status).toBe(200); + + await app.request("/_emulate/faults", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ match: { method: "GET", pathPattern: "/things" }, response: { status: 500 } }), + }); + await app.request("/_emulate/faults", { method: "DELETE" }); + faults = (await (await app.request("/_emulate/faults")).json()) as { faults: Array<{ id: string }> }; + expect(faults.faults).toHaveLength(0); + }); + it("runs the supplied reset callback", async () => { let resets = 0; const { app, store, ledger } = createServer(plugin, { @@ -111,6 +188,11 @@ describe("control plane", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "created" }), }); + await app.request("/_emulate/faults", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ match: { method: "GET", pathPattern: "/unused" }, response: { status: 503 } }), + }); let things = (await (await app.request("/things")).json()) as { things: Thing[] }; expect(things.things).toHaveLength(2); @@ -121,6 +203,8 @@ describe("control plane", () => { things = (await (await app.request("/things")).json()) as { things: Thing[] }; expect(things.things).toHaveLength(1); expect(things.things[0]!.name).toBe("seeded"); + const faults = (await (await app.request("/_emulate/faults")).json()) as { faults: unknown[] }; + expect(faults.faults).toHaveLength(0); }); it("creates default bearer credentials through the control plane", async () => { diff --git a/packages/@emulators/core/src/client.ts b/packages/@emulators/core/src/client.ts index 5a4c4157..68cf1f9c 100644 --- a/packages/@emulators/core/src/client.ts +++ b/packages/@emulators/core/src/client.ts @@ -4,6 +4,7 @@ // consumers never hand-roll fetch calls or re-cast response shapes the server // already types. import type { CredentialRequest, IssuedCredential } from "./control-plane.js"; +import type { ArmedFault, FaultArmInput } from "./faults.js"; import type { LedgerEntry } from "./ledger.js"; import type { EmulatorInstanceInfo, @@ -121,6 +122,20 @@ export class EmulatorClient { }, }; + readonly faults = { + arm: async (request: FaultArmInput): Promise => { + const body = await this.#json<{ fault: ArmedFault }>("POST", "/_emulate/faults", request); + return body.fault; + }, + list: async (): Promise => { + const body = await this.#json<{ faults: ArmedFault[] }>("GET", "/_emulate/faults"); + return body.faults; + }, + clear: async (id?: string): Promise => { + await this.#json("DELETE", id ? `/_emulate/faults/${encodeURIComponent(id)}` : "/_emulate/faults"); + }, + }; + readonly credentials = { mint: async (request: CredentialRequest = {}): Promise => { const body = await this.#json<{ credential: IssuedCredential }>("POST", "/_emulate/credentials", request); diff --git a/packages/@emulators/core/src/control-plane.ts b/packages/@emulators/core/src/control-plane.ts index d121ae79..674e8315 100644 --- a/packages/@emulators/core/src/control-plane.ts +++ b/packages/@emulators/core/src/control-plane.ts @@ -7,6 +7,7 @@ import type { ConnectionVars, EmulatorInstanceInfo, ResolvedConnection, ServiceM import { coverageReport, enrichManifest, resolveConnections } from "./manifest.js"; import type { TokenMap } from "./middleware/auth.js"; import { escapeHtml, renderCardPage } from "./ui.js"; +import type { FaultArmInput, FaultRegistry } from "./faults.js"; export interface CredentialRequest { type?: string; @@ -39,6 +40,7 @@ export interface ControlPlaneOptions { store: Store; webhooks: WebhookDispatcher; ledger: RequestLedger; + faults: FaultRegistry; tokenMap?: TokenMap; /** True when the host persists the ledger across eviction (e.g. a Durable Object). */ ledgerPersistent?: boolean; @@ -97,7 +99,7 @@ function connectionVars( } export function registerControlPlane(app: Hono, options: ControlPlaneOptions): void { - const { instance, store, webhooks, ledger } = options; + const { instance, store, webhooks, ledger, faults } = options; const manifest = enrichManifest(options.manifest, { ledgerPersistent: options.ledgerPersistent }); const hostSuffix = options.hostSuffix ?? "emulators.dev"; @@ -137,6 +139,24 @@ export function registerControlPlane(app: Hono, options: ControlPlaneOpt ledger.clear(); return c.json({ ok: true }); }); + app.get("/_emulate/faults", (c) => c.json({ faults: faults.list() })); + app.post("/_emulate/faults", async (c) => { + const body = (await c.req.json().catch(() => undefined)) as FaultArmInput | undefined; + try { + const fault = faults.arm(body as FaultArmInput); + return c.json({ fault }); + } catch (err) { + return c.json({ error: "invalid_fault", message: err instanceof Error ? err.message : "Invalid fault." }, 400); + } + }); + app.delete("/_emulate/faults", (c) => { + faults.clear(); + return c.json({ ok: true }); + }); + app.delete("/_emulate/faults/:id", (c) => { + const removed = faults.clear(c.req.param("id")); + return c.json({ ok: true, removed }); + }); app.get("/_emulate/logs", (c) => c.json({ webhooks: webhooks.getDeliveries(), requests: ledger.list(100) })); app.post("/_emulate/reset", async (c) => { if (options.reset) { @@ -146,6 +166,7 @@ export function registerControlPlane(app: Hono, options: ControlPlaneOpt webhooks.clear(); ledger.clear(); } + faults.clear(); return c.json({ ok: true }); }); app.post("/_emulate/seed", async (c) => { @@ -251,7 +272,7 @@ function renderConnectionsHtml(connections: ResolvedConnection[]): string { } function controlLinks(): string { - const routes = ["manifest", "quickstart", "specs", "coverage", "connections", "state", "ledger", "logs"]; + const routes = ["manifest", "quickstart", "specs", "coverage", "connections", "state", "ledger", "faults", "logs"]; return routes.map((r) => `${r}`).join(" | "); } @@ -274,10 +295,17 @@ export function renderQuickstart(manifest: ServiceManifest, instance: EmulatorIn `- ${instance.controlBaseUrl}/connections`, `- ${instance.controlBaseUrl}/state`, `- ${instance.controlBaseUrl}/ledger`, + `- ${instance.controlBaseUrl}/faults`, `- POST ${instance.controlBaseUrl}/credentials`, `- POST ${instance.controlBaseUrl}/seed`, `- POST ${instance.controlBaseUrl}/reset`, "", + "Fault injection:", + "Arm a one-shot provider failure with a glob pathPattern, then inspect /ledger for faulted: true.", + `curl -s -X POST ${instance.controlBaseUrl}/faults \\`, + ` -H "content-type: application/json" \\`, + ` -d '{"match":{"method":"GET","pathPattern":"/v1/*"},"response":{"status":503,"body":{"error":"temporary"}}}'`, + "", "Connect:", ...connections.flatMap((c) => ["", `## ${c.title}`, c.body]), ]; diff --git a/packages/@emulators/core/src/faults.ts b/packages/@emulators/core/src/faults.ts new file mode 100644 index 00000000..2fef0560 --- /dev/null +++ b/packages/@emulators/core/src/faults.ts @@ -0,0 +1,240 @@ +import type { MiddlewareHandler } from "./http.js"; +import type { LedgerEntry } from "./ledger.js"; +import type { OperationCoverage, ServiceManifest } from "./manifest.js"; +import type { AppEnv } from "./middleware/auth.js"; + +export interface FaultMatch { + operationId?: string; + method?: string; + pathPattern?: string; +} + +export interface FaultResponse { + status: number; + body?: unknown; + headers?: Record; +} + +export interface FaultArmInput { + match: FaultMatch; + response: FaultResponse; + times?: number; + delayMs?: number; +} + +export interface ArmedFault { + id: string; + match: FaultMatch; + response: FaultResponse; + times: number; + remaining: number; + delayMs?: number; + createdAt: string; +} + +export interface FaultLedgerMarker { + faulted: true; + faultId: string; +} + +export class FaultRegistry { + private faults: ArmedFault[] = []; + private counter = 1; + + arm(input: FaultArmInput): ArmedFault { + validateFaultInput(input); + const fault: ArmedFault = { + id: `fault_${this.counter++}`, + match: normalizeMatch(input.match), + response: { + status: input.response.status, + ...(input.response.body === undefined ? {} : { body: input.response.body }), + ...(input.response.headers ? { headers: input.response.headers } : {}), + }, + times: input.times ?? 1, + remaining: input.times ?? 1, + ...(input.delayMs === undefined ? {} : { delayMs: input.delayMs }), + createdAt: new Date().toISOString(), + }; + this.faults.push(fault); + return cloneFault(fault); + } + + list(): ArmedFault[] { + return this.faults.map(cloneFault); + } + + clear(id?: string): boolean { + if (id === undefined) { + const hadFaults = this.faults.length > 0; + this.faults.length = 0; + return hadFaults; + } + const before = this.faults.length; + this.faults = this.faults.filter((fault) => fault.id !== id); + return this.faults.length !== before; + } + + consume(request: FaultRequest): ArmedFault | null { + const index = this.faults.findIndex((fault) => matchesFault(fault, request)); + if (index === -1) return null; + const fault = this.faults[index]!; + fault.remaining -= 1; + if (fault.remaining <= 0) { + this.faults.splice(index, 1); + } + return cloneFault({ ...fault, remaining: Math.max(0, fault.remaining) }); + } +} + +export function createFaultMiddleware( + faults: FaultRegistry, + manifest: ServiceManifest, +): MiddlewareHandler { + const operations = manifest.specs.flatMap((spec) => spec.operations ?? []); + + return async (c, next) => { + if (c.req.path.startsWith("/_emulate")) { + await next(); + return; + } + + const url = new URL(c.req.url); + const operation = resolveOperation(operations, c.req.method, url.pathname); + if (operation?.operationId) { + c.set("operationId", operation.operationId); + } + + const fault = faults.consume({ + operationId: operation?.operationId, + method: c.req.method, + path: url.pathname, + }); + if (!fault) { + await next(); + return; + } + + c.set("fault", { faulted: true, faultId: fault.id }); + if (fault.delayMs && fault.delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, fault.delayMs)); + } + return faultResponse(fault.response); + }; +} + +export function faultLedgerFields(c: { get: (key: "fault") => FaultLedgerMarker | undefined }): Pick< + LedgerEntry, + "faulted" | "faultId" +> { + const fault = c.get("fault"); + return fault ? { faulted: true, faultId: fault.faultId } : {}; +} + +interface FaultRequest { + operationId?: string; + method: string; + path: string; +} + +function validateFaultInput(input: FaultArmInput): void { + if (!input || typeof input !== "object") throw new Error("Fault body is required."); + if (!input.match || typeof input.match !== "object") throw new Error("Fault match is required."); + if (!input.response || typeof input.response !== "object") throw new Error("Fault response is required."); + if (!input.match.operationId && !input.match.method && !input.match.pathPattern) { + throw new Error("Fault match must include operationId, method, or pathPattern."); + } + if (!Number.isInteger(input.response.status) || input.response.status < 100 || input.response.status > 599) { + throw new Error("Fault response.status must be an HTTP status code."); + } + if (input.response.headers !== undefined) { + if (!input.response.headers || typeof input.response.headers !== "object" || Array.isArray(input.response.headers)) { + throw new Error("Fault response.headers must be an object."); + } + for (const [key, value] of Object.entries(input.response.headers)) { + if (typeof key !== "string" || typeof value !== "string") { + throw new Error("Fault response.headers must contain string keys and values."); + } + } + } + if (input.times !== undefined && (!Number.isInteger(input.times) || input.times < 1)) { + throw new Error("Fault times must be a positive integer."); + } + if (input.delayMs !== undefined && (!Number.isInteger(input.delayMs) || input.delayMs < 0)) { + throw new Error("Fault delayMs must be a non-negative integer."); + } +} + +function normalizeMatch(match: FaultMatch): FaultMatch { + return { + ...(match.operationId ? { operationId: match.operationId } : {}), + ...(match.method ? { method: match.method.toUpperCase() } : {}), + ...(match.pathPattern ? { pathPattern: match.pathPattern } : {}), + }; +} + +function matchesFault(fault: ArmedFault, request: FaultRequest): boolean { + if (fault.match.operationId && fault.match.operationId !== request.operationId) return false; + if (fault.match.method && fault.match.method.toUpperCase() !== request.method.toUpperCase()) return false; + if (fault.match.pathPattern && !globToRegExp(fault.match.pathPattern).test(request.path)) return false; + return true; +} + +function resolveOperation(operations: OperationCoverage[], method: string, path: string): OperationCoverage | undefined { + return operations.find((operation) => { + if (!operation.method || !operation.path) return false; + if (operation.method.toUpperCase() !== method.toUpperCase()) return false; + return routePathToRegExp(operation.path).test(path); + }); +} + +function routePathToRegExp(pattern: string): RegExp { + const source = pattern + .split("/") + .map((part) => { + if (part.startsWith(":") || (part.startsWith("{") && part.endsWith("}"))) return "[^/]+"; + return escapeRegExp(part); + }) + .join("/"); + return new RegExp(`^${source}$`); +} + +function globToRegExp(pattern: string): RegExp { + let source = ""; + for (const char of pattern) { + if (char === "*") { + source += ".*"; + } else { + source += escapeRegExp(char); + } + } + return new RegExp(`^${source}$`); +} + +function faultResponse(response: FaultResponse): Response { + const headers = new Headers(response.headers); + if (response.body === undefined) { + return new Response(null, { status: response.status, headers }); + } + if (!headers.has("content-type")) { + headers.set("content-type", "application/json"); + } + const contentType = headers.get("content-type") ?? ""; + const body = + contentType.includes("application/json") && typeof response.body !== "string" + ? JSON.stringify(response.body) + : String(response.body); + return new Response(body, { status: response.status, headers }); +} + +function cloneFault(fault: ArmedFault): ArmedFault { + return { + ...fault, + match: { ...fault.match }, + response: { ...fault.response, headers: fault.response.headers ? { ...fault.response.headers } : undefined }, + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/packages/@emulators/core/src/index.ts b/packages/@emulators/core/src/index.ts index 19a73ede..4cb68d78 100644 --- a/packages/@emulators/core/src/index.ts +++ b/packages/@emulators/core/src/index.ts @@ -72,6 +72,15 @@ export { type LedgerSnapshot, type LedgerWebhookDelivery, } from "./ledger.js"; +export { + FaultRegistry, + createFaultMiddleware, + type ArmedFault, + type FaultArmInput, + type FaultLedgerMarker, + type FaultMatch, + type FaultResponse, +} from "./faults.js"; export { registerControlPlane, renderLandingPage, diff --git a/packages/@emulators/core/src/ledger.ts b/packages/@emulators/core/src/ledger.ts index b15fa014..b1a5597c 100644 --- a/packages/@emulators/core/src/ledger.ts +++ b/packages/@emulators/core/src/ledger.ts @@ -1,6 +1,7 @@ import type { MiddlewareHandler } from "./http.js"; import type { AppEnv, AuthUser, AuthApp } from "./middleware/auth.js"; import type { WebhookDispatcher } from "./webhooks.js"; +import { faultLedgerFields } from "./faults.js"; export interface LedgerIdentity { user?: Pick; @@ -36,6 +37,10 @@ export interface LedgerEntry { route?: string; /** Provider operation id, when the handler advertises one. */ operationId?: string; + /** True when the response was injected by the shared one-shot fault system. */ + faulted?: boolean; + /** The armed fault id that produced this response. */ + faultId?: string; request: { headers: Record; body?: unknown; @@ -181,6 +186,7 @@ export function createLedgerMiddleware(ledger: RequestLedger, options: LedgerOpt query: url.search, route, operationId, + ...faultLedgerFields(c), request: { headers: requestHeaders, ...requestBody, diff --git a/packages/@emulators/core/src/middleware/auth.ts b/packages/@emulators/core/src/middleware/auth.ts index 24287e26..de12fce4 100644 --- a/packages/@emulators/core/src/middleware/auth.ts +++ b/packages/@emulators/core/src/middleware/auth.ts @@ -58,6 +58,8 @@ export type AppEnv = { correlationId?: string; /** Provider operation id a handler can advertise for the ledger. */ operationId?: string; + /** One-shot fault marker set by the shared fault injection middleware. */ + fault?: import("../faults.js").FaultLedgerMarker; /** Side effects a handler records onto the active request's ledger entry. */ ledgerEffects?: import("../ledger.js").LedgerSideEffect[]; }; diff --git a/packages/@emulators/core/src/server.ts b/packages/@emulators/core/src/server.ts index 663691aa..3c38d821 100644 --- a/packages/@emulators/core/src/server.ts +++ b/packages/@emulators/core/src/server.ts @@ -14,6 +14,7 @@ import { registerFontRoutes } from "./fonts.js"; import { RequestLedger, createLedgerMiddleware } from "./ledger.js"; import { registerControlPlane, type CredentialRequest, type IssuedCredential } from "./control-plane.js"; import { createDefaultManifest, type ServiceManifest } from "./manifest.js"; +import { FaultRegistry, createFaultMiddleware } from "./faults.js"; export interface ServerOptions { port?: number; @@ -42,6 +43,7 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) const store = new Store(); const webhooks = new WebhookDispatcher(); const ledger = new RequestLedger(); + const faults = new FaultRegistry(); const tokenMap: TokenMap = new Map(); if (options.tokens) { @@ -77,6 +79,7 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) store, webhooks, ledger, + faults, tokenMap, ledgerPersistent: options.ledgerPersistent, hostSuffix: options.hostSuffix, @@ -87,6 +90,7 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) } app.use("*", createLedgerMiddleware(ledger, { webhooks })); + app.use("*", createFaultMiddleware(faults, options.manifest ?? createDefaultManifest(plugin.name))); const rateLimitCounters = new Map(); let lastPruneAt = Math.floor(Date.now() / 1000); @@ -140,5 +144,5 @@ export function createServer(plugin: ServicePlugin, options: ServerOptions = {}) ), ); - return { app, store, webhooks, ledger, port, baseUrl, tokenMap }; + return { app, store, webhooks, ledger, faults, port, baseUrl, tokenMap }; } diff --git a/packages/emulate/src/__tests__/api.test.ts b/packages/emulate/src/__tests__/api.test.ts index e9466082..97a8f566 100644 --- a/packages/emulate/src/__tests__/api.test.ts +++ b/packages/emulate/src/__tests__/api.test.ts @@ -172,6 +172,61 @@ describe("createEmulator", () => { await spotify.close(); }); + it("injects a one-shot fault into a real service emulator", async () => { + const spotify = await createEmulator({ service: "spotify", port: 14051 }); + + const credential = await spotify.credentials.mint({ type: "oauth-client-credentials", name: "Fault Test" }); + const basic = Buffer.from(`${credential.client_id}:${credential.client_secret}`).toString("base64"); + const tokenRes = await fetch(`${spotify.url}/api/token`, { + method: "POST", + headers: { + Authorization: `Basic ${basic}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: "grant_type=client_credentials", + }); + const token = (await tokenRes.json()) as { access_token: string }; + + const fault = await spotify.faults.arm({ + match: { operationId: "search" }, + response: { status: 503, body: { error: { message: "planned outage" } } }, + }); + expect((await spotify.faults.list()).map((f) => f.id)).toContain(fault.id); + + const searchUrl = `${spotify.url}/v1/search?q=daft&type=artist`; + const faulted = await fetch(searchUrl, { headers: { Authorization: `Bearer ${token.access_token}` } }); + expect(faulted.status).toBe(503); + expect(await faulted.json()).toEqual({ error: { message: "planned outage" } }); + + const ledger = await spotify.ledger.list(); + expect(ledger[0]).toMatchObject({ + path: "/v1/search", + operationId: "search", + response: { status: 503 }, + faulted: true, + faultId: fault.id, + }); + + const normal = await fetch(searchUrl, { headers: { Authorization: `Bearer ${token.access_token}` } }); + expect(normal.status).toBe(200); + const normalBody = (await normal.json()) as { artists: { items: unknown[] } }; + expect(normalBody.artists.items.length).toBeGreaterThan(0); + expect(await spotify.faults.list()).toHaveLength(0); + + const cleared = await spotify.faults.arm({ + match: { method: "GET", pathPattern: "/v1/search" }, + response: { status: 429 }, + }); + await spotify.faults.clear(cleared.id); + expect(await spotify.faults.list()).toHaveLength(0); + + await spotify.faults.arm({ match: { method: "GET", pathPattern: "/v1/*" }, response: { status: 500 } }); + await spotify.faults.clear(); + expect(await spotify.faults.list()).toHaveLength(0); + + await spotify.close(); + }); + it("creates Microsoft OAuth client credentials and exchanges them for a Graph app token", async () => { const microsoft = await createEmulator({ service: "microsoft", port: 14055 }); diff --git a/packages/emulate/src/api.ts b/packages/emulate/src/api.ts index bb67b2ed..82fbce63 100644 --- a/packages/emulate/src/api.ts +++ b/packages/emulate/src/api.ts @@ -4,9 +4,13 @@ export type { ServiceName } from "./registry.js"; export { EmulatorClient, EmulatorControlError, + type ArmedFault, type ConnectionsQuery, type CoverageResponse, type CredentialRequest, + type FaultArmInput, + type FaultMatch, + type FaultResponse, type IssuedCredential, type LedgerEntry, type LedgerIdentity, @@ -111,7 +115,7 @@ export async function createEmulator(options: EmulatorOptions): Promise {}; let applyRuntimeSeed = (_seed: unknown) => {}; - const { app, store, webhooks, ledger, tokenMap } = createServer(loaded.plugin, { + const { app, store, webhooks, ledger, faults, tokenMap } = createServer(loaded.plugin, { port, baseUrl, tokens, @@ -128,6 +132,7 @@ export async function createEmulator(options: EmulatorOptions): Promise { webhooks.clear(); ledger.clear(); + faults.clear(); loaded.plugin.seed?.(store, baseUrl); if (svcSeedConfig && loaded.seedFromConfig) { loaded.seedFromConfig(store, baseUrl, svcSeedConfig, webhooks); diff --git a/packages/emulate/src/index.ts b/packages/emulate/src/index.ts index c63dedf6..cd52d63e 100644 --- a/packages/emulate/src/index.ts +++ b/packages/emulate/src/index.ts @@ -39,6 +39,7 @@ Control plane (under /_emulate on each service): GET /_emulate/mcp MCP surface (when supported) GET /_emulate/state current emulator state GET /_emulate/ledger request ledger (DELETE to clear) + GET /_emulate/faults armed one-shot faults (POST to arm, DELETE to clear) GET /_emulate/logs webhook deliveries and recent requests POST /_emulate/instances create an instance POST /_emulate/seed seed state @@ -51,7 +52,8 @@ Global catalog: Use /_emulate/manifest and /_emulate/coverage to discover supported surfaces and honest coverage, /_emulate/credentials to create credentials, - /_emulate/seed to load fixtures, and /_emulate/ledger to validate API calls. + /_emulate/seed to load fixtures, /_emulate/faults to arm one-shot failures, + and /_emulate/ledger to validate API calls. Hosted services: Available services include vercel, github, gitlab, google, slack, apple, diff --git a/skills/apple/SKILL.md b/skills/apple/SKILL.md index 3f6686c6..9c7e9ab5 100644 --- a/skills/apple/SKILL.md +++ b/skills/apple/SKILL.md @@ -252,6 +252,6 @@ curl -X POST "$APPLE_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"oauth-authorization-code","redirect_uris":["http://localhost:3000/api/auth/callback/apple"]}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Apple is at `https://apple.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `apple..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/apple`. diff --git a/skills/autumn/SKILL.md b/skills/autumn/SKILL.md index 06837384..22196e53 100644 --- a/skills/autumn/SKILL.md +++ b/skills/autumn/SKILL.md @@ -53,4 +53,4 @@ curl -X POST "$AUTUMN_EMULATOR_URL/checkout/settle" -H "Content-Type: applicatio This deferral lets a test reproduce the real "page is stale until reload" race: the redirect back lands before the subscription is active. -Inspect calls at `GET /_emulate/ledger`; reset with `POST /_emulate/reset`. +Inspect calls at `GET /_emulate/ledger`; reset with `POST /_emulate/reset`. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. diff --git a/skills/aws/SKILL.md b/skills/aws/SKILL.md index ba7a0a74..c03714f7 100644 --- a/skills/aws/SKILL.md +++ b/skills/aws/SKILL.md @@ -396,6 +396,6 @@ curl -X POST "$AWS_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"api-key","login":"admin"}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted AWS is at `https://aws.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `aws..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/aws`. diff --git a/skills/clerk/SKILL.md b/skills/clerk/SKILL.md index b64c1044..d7625f0c 100644 --- a/skills/clerk/SKILL.md +++ b/skills/clerk/SKILL.md @@ -38,6 +38,6 @@ curl -X POST "$CLERK_EMULATOR_URL/_emulate/credentials" \ Inspect `GET /_emulate/manifest` first to confirm supported surfaces (REST, OAuth/OIDC), auth capabilities, and per-operation spec coverage. Use `GET /_emulate/connections` for copyable SDK, CLI, env, and curl snippets and `GET /_emulate/quickstart` for setup notes. -Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here a Clerk secret key or an OAuth client, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here a Clerk secret key or an OAuth client, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Clerk is at `https://clerk.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `clerk..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/clerk`. diff --git a/skills/emulate/SKILL.md b/skills/emulate/SKILL.md index f2b6df07..ca0ae5eb 100644 --- a/skills/emulate/SKILL.md +++ b/skills/emulate/SKILL.md @@ -50,6 +50,10 @@ Provider traffic and control-plane traffic are separate. Provider routes stay fa | `GET /_emulate/state` | Current emulator store snapshot | | `GET /_emulate/ledger` | Recent API calls with sensitive fields redacted (optional `?limit=`) | | `DELETE /_emulate/ledger` | Clear the request ledger | +| `POST /_emulate/faults` | Arm a one-shot or counted fault against matching provider requests | +| `GET /_emulate/faults` | List armed faults with remaining counts | +| `DELETE /_emulate/faults` | Clear all armed faults | +| `DELETE /_emulate/faults/:id` | Clear one armed fault | | `GET /_emulate/logs` | Webhook deliveries plus recent requests | | `POST /_emulate/instances` | Return URLs for a lazily created hosted instance | | `POST /_emulate/seed` | Add runtime seed data using the service seed schema | @@ -97,11 +101,16 @@ The request ledger is a core feature, not a debug afterthought. `GET /_emulate/l - sanitized request headers and body - authenticated identity - response status and a one-line summary +- `faulted` and `faultId` when `/_emulate/faults` injected the response - `sideEffects` and `webhookDeliveries` - `durationMs` Send `X-Correlation-Id` on a request to trace it end to end, then read it back from the matching ledger entry. Use `sideEffects` and `webhookDeliveries` to assert what an application's call actually did. +### Fault injection + +Use `POST /_emulate/faults` to arm a one-shot or counted response for matching provider requests. The body is `{ "match": { "operationId": "...", "method": "GET", "pathPattern": "/v1/*" }, "response": { "status": 503, "body": { "error": "temporary" } }, "times": 1 }`. Match criteria are combined, and `pathPattern` is a glob where `*` matches any characters in the request path. Matched requests short-circuit, decrement `remaining`, and still appear in `GET /_emulate/ledger` with `faulted: true` and `faultId`. + ## Host-Based Routing (Deployed Emulators) Hosted emulators are reachable on `*.emulators.dev`. All services are available: diff --git a/skills/github/SKILL.md b/skills/github/SKILL.md index 214b9510..d72ce3c7 100644 --- a/skills/github/SKILL.md +++ b/skills/github/SKILL.md @@ -608,6 +608,6 @@ curl -X POST "$GITHUB_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"oauth-authorization-code","redirect_uris":["http://localhost:3000/api/auth/callback/github"]}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted GitHub is at `https://github.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `github..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/github`. diff --git a/skills/google/SKILL.md b/skills/google/SKILL.md index 24d24f07..e7f4d74f 100644 --- a/skills/google/SKILL.md +++ b/skills/google/SKILL.md @@ -611,6 +611,6 @@ curl -X POST "$GOOGLE_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"oauth-authorization-code","redirect_uris":["http://localhost:3000/api/auth/callback/google"]}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Google is at `https://google.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `google..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/google`. diff --git a/skills/microsoft/SKILL.md b/skills/microsoft/SKILL.md index 87858318..1696305e 100644 --- a/skills/microsoft/SKILL.md +++ b/skills/microsoft/SKILL.md @@ -425,6 +425,6 @@ curl -X POST "$MICROSOFT_EMULATOR_URL/_emulate/credentials" \ For app-only scenarios, mint the same OAuth client, then exchange it with `grant_type=client_credentials` and `scope=https://graph.microsoft.com/.default`. -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Microsoft is at `https://microsoft.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `microsoft..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/microsoft`. diff --git a/skills/mongoatlas/SKILL.md b/skills/mongoatlas/SKILL.md index 97abe5a4..48c12ea7 100644 --- a/skills/mongoatlas/SKILL.md +++ b/skills/mongoatlas/SKILL.md @@ -32,6 +32,6 @@ Use the returned token as a bearer token for Admin API and Data API calls. Inspect `GET /_emulate/manifest` first to confirm supported surfaces (Atlas Admin API, Data API), auth capabilities, and per-operation spec coverage. Use `GET /_emulate/connections` for copyable SDK, CLI, env, and curl snippets and `GET /_emulate/quickstart` for setup notes. -Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here an API-key style bearer credential, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here an API-key style bearer credential, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted MongoDB Atlas is at `https://mongoatlas.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `mongoatlas..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/mongoatlas`. diff --git a/skills/next/SKILL.md b/skills/next/SKILL.md index 66b772a9..89560506 100644 --- a/skills/next/SKILL.md +++ b/skills/next/SKILL.md @@ -181,6 +181,6 @@ The built-in `filePersistence(path)` from `@emulators/core` provides a file-base Each embedded service exposes the same `/_emulate` control plane on the Next.js origin (for example `/api/emulate/github/_emulate/manifest`, depending on how the adapter is mounted). Inspect `GET /_emulate/manifest` first to confirm supported surfaces, auth capabilities, and per-operation spec coverage. Use `GET /_emulate/connections` for copyable SDK, CLI, env, and curl snippets and `GET /_emulate/quickstart` for setup notes. -Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (a bearer token, API key, or OAuth client depending on the service's auth capabilities). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (a bearer token, API key, or OAuth client depending on the service's auth capabilities). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. The same emulators are also hosted on `*.emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/`. diff --git a/skills/okta/SKILL.md b/skills/okta/SKILL.md index 6395e793..1e65649e 100644 --- a/skills/okta/SKILL.md +++ b/skills/okta/SKILL.md @@ -30,6 +30,6 @@ curl -X POST "$OKTA_EMULATOR_URL/_emulate/credentials" \ Inspect `GET /_emulate/manifest` first to confirm supported surfaces (OAuth 2.0, OIDC, management APIs), auth capabilities, and per-operation spec coverage. Use `GET /_emulate/connections` for copyable SDK, CLI, env, and curl snippets and `GET /_emulate/quickstart` for setup notes. -Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here an OAuth/OIDC client, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here an OAuth/OIDC client, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Okta is at `https://okta.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `okta..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/okta`. diff --git a/skills/posthog/SKILL.md b/skills/posthog/SKILL.md index 7760103d..b11adfa2 100644 --- a/skills/posthog/SKILL.md +++ b/skills/posthog/SKILL.md @@ -36,6 +36,6 @@ For local tests, loopback HTTP metadata URLs are accepted. Non-HTTPS, non-loopba Inspect `GET /_emulate/manifest` first to confirm supported surfaces, auth capabilities, and per-operation spec coverage. Use `GET /_emulate/openapi` for the OpenAPI document, `GET /_emulate/connections` for copyable snippets, and `GET /_emulate/quickstart` for setup notes. -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id, matched route, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add users, projects, and events, and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id, matched route, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add users, projects, and events, and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted PostHog is at `https://posthog.emulators.dev` with instance hosts of the form `posthog..emulators.dev`. Per-service docs live at `https://docs.emulators.dev/posthog`. diff --git a/skills/resend/SKILL.md b/skills/resend/SKILL.md index 5afd1828..ae654020 100644 --- a/skills/resend/SKILL.md +++ b/skills/resend/SKILL.md @@ -350,6 +350,6 @@ curl -X POST "$RESEND_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"api-key","login":"admin"}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Resend is at `https://resend.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `resend..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/resend`. diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md index aa257ef5..6db80b43 100644 --- a/skills/slack/SKILL.md +++ b/skills/slack/SKILL.md @@ -740,6 +740,6 @@ curl -X POST "$SLACK_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"oauth-authorization-code","redirect_uris":["http://localhost:3000/api/auth/callback/slack"]}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries (the `event_callback` payloads dispatched on state changes). Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries (the `event_callback` payloads dispatched on state changes). Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Slack is at `https://slack.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `slack..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/slack`. diff --git a/skills/spotify/SKILL.md b/skills/spotify/SKILL.md index 7dba96b8..3e4b4da5 100644 --- a/skills/spotify/SKILL.md +++ b/skills/spotify/SKILL.md @@ -39,6 +39,6 @@ curl -X POST "$SPOTIFY_EMULATOR_URL/api/token" \ Inspect `GET /_emulate/manifest` first to confirm supported surfaces (Web API, Client Credentials OAuth), auth capabilities, and per-operation spec coverage. Use `GET /_emulate/openapi` for the OpenAPI document, `GET /_emulate/connections` for copyable SDK, CLI, env, and curl snippets, and `GET /_emulate/quickstart` for setup notes. -Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here a Client Credentials app, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Mint credentials with `POST /_emulate/credentials`, the canonical, uniform way to create a credential for any service (here a Client Credentials app, as shown above). Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Spotify is at `https://spotify.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `spotify..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/spotify`. diff --git a/skills/stripe/SKILL.md b/skills/stripe/SKILL.md index d4a6d632..231511fa 100644 --- a/skills/stripe/SKILL.md +++ b/skills/stripe/SKILL.md @@ -396,6 +396,6 @@ curl -X POST "$STRIPE_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"api-key","login":"admin"}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries (the Stripe events dispatched on state changes). Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries (the Stripe events dispatched on state changes). Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Stripe is at `https://stripe.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `stripe..emulators.dev` (e.g. `stripe.ci-48291.emulators.dev`). The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/stripe`. diff --git a/skills/vercel/SKILL.md b/skills/vercel/SKILL.md index 219b2e0c..17b577ee 100644 --- a/skills/vercel/SKILL.md +++ b/skills/vercel/SKILL.md @@ -440,6 +440,6 @@ curl -X POST "$VERCEL_EMULATOR_URL/_emulate/credentials" \ -d '{"type":"oauth-authorization-code","redirect_uris":["http://localhost:3000/api/auth/callback/vercel"]}' ``` -Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. +Inspect calls with `GET /_emulate/ledger`: each entry includes a correlation id (set `X-Correlation-Id` on a request to trace it), the matched route and operation id, sanitized headers and body, authenticated identity, response status, side effects, and webhook deliveries. Use `POST /_emulate/seed` to add runtime seed data and `POST /_emulate/reset` to replay seeds. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. Hosted Vercel is at `https://vercel.emulators.dev` (the bare service host is useful without an instance) with instance hosts of the form `vercel..emulators.dev`. The apex `https://emulators.dev` is a links-out catalog of every emulator; discover the same catalog machine-readably at `GET /_emulate/services` from any host. Per-service docs live at `https://docs.emulators.dev/vercel`. diff --git a/skills/workos/SKILL.md b/skills/workos/SKILL.md index 619adcfc..2bdbfd83 100644 --- a/skills/workos/SKILL.md +++ b/skills/workos/SKILL.md @@ -51,4 +51,4 @@ curl -X POST "$WORKOS_EMULATOR_URL/_emulate/seed" -H "Content-Type: application/ }' ``` -Inspect calls at `GET /_emulate/ledger`; reset with `POST /_emulate/reset`. +Inspect calls at `GET /_emulate/ledger`; reset with `POST /_emulate/reset`. Use `POST /_emulate/faults` to arm one-shot failures; matching faulted requests show `faulted: true` and `faultId` in the ledger. From 06274e792740b8e49278601a85fdc48fde125815 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:47:35 -0700 Subject: [PATCH 2/3] Release 0.10.0 --- CHANGELOG.md | 10 +++++++++- packages/@emulators/adapter-next/package.json | 2 +- packages/@emulators/apple/package.json | 2 +- packages/@emulators/autumn/package.json | 2 +- packages/@emulators/aws/package.json | 2 +- packages/@emulators/clerk/package.json | 2 +- packages/@emulators/cloudflare/package.json | 2 +- packages/@emulators/core/package.json | 2 +- packages/@emulators/github/package.json | 2 +- packages/@emulators/gitlab/package.json | 2 +- packages/@emulators/google/package.json | 2 +- packages/@emulators/mcp/package.json | 2 +- packages/@emulators/microsoft/package.json | 2 +- packages/@emulators/mongoatlas/package.json | 2 +- packages/@emulators/okta/package.json | 2 +- packages/@emulators/posthog/package.json | 2 +- packages/@emulators/resend/package.json | 2 +- packages/@emulators/slack/package.json | 2 +- packages/@emulators/spotify/package.json | 2 +- packages/@emulators/stripe/package.json | 2 +- packages/@emulators/vercel/package.json | 2 +- packages/@emulators/workos/package.json | 2 +- packages/@emulators/x/package.json | 2 +- packages/emulate/package.json | 2 +- 24 files changed, 32 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a88dd0c4..85cee62c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ # Changelog -## 0.9.1 +## 0.10.0 +### New Features + +- **One-shot fault injection** — every emulator's control plane can now arm failures against otherwise-real endpoints: `POST /_emulate/faults` with a match (`operationId`, `method`, and/or a `pathPattern` glob) and a response (`status`, optional `body`/`headers`/`delayMs`) makes the next `times` matching requests short-circuit with that response, then disarms. Faulted requests still land in the request ledger marked `faulted: true` with the `faultId`, so a test can prove the fault fired and that the caller's fallback ran. Typed surface on both `createEmulator` and `connectEmulator`: `client.faults.arm(...)`, `.list()`, `.clear(id?)`. `reset()` clears armed faults. This is the missing piece for exercising error paths (failed token exchanges, rejected dynamic client registrations, flaky upstreams) against real-shaped services instead of hand-written mocks. + + + +## 0.9.1 + ### Bug Fixes - **Autumn customer balances carry their feature** — 0.9.0's per-feature `balances` on `customers.get_or_create` omitted the nested `feature` object, but autumn-js's `useCustomer` always requests `expand: ["balances.feature"]` and asserts the expansion, so every consuming UI render threw `[customerToFeatures] please expand balances.feature` into the app's error boundary. Every balance entry now embeds its feature, matching the real API's expanded shape. diff --git a/packages/@emulators/adapter-next/package.json b/packages/@emulators/adapter-next/package.json index eb83ace3..c551b88a 100644 --- a/packages/@emulators/adapter-next/package.json +++ b/packages/@emulators/adapter-next/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/adapter-next", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/apple/package.json b/packages/@emulators/apple/package.json index 4a423af7..5eb6cb92 100644 --- a/packages/@emulators/apple/package.json +++ b/packages/@emulators/apple/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/apple", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/autumn/package.json b/packages/@emulators/autumn/package.json index de5e817b..42b0010c 100644 --- a/packages/@emulators/autumn/package.json +++ b/packages/@emulators/autumn/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/autumn", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/aws/package.json b/packages/@emulators/aws/package.json index 137520f4..0f216b11 100644 --- a/packages/@emulators/aws/package.json +++ b/packages/@emulators/aws/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/aws", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/clerk/package.json b/packages/@emulators/clerk/package.json index f79213d3..cfdb90b3 100644 --- a/packages/@emulators/clerk/package.json +++ b/packages/@emulators/clerk/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/clerk", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/cloudflare/package.json b/packages/@emulators/cloudflare/package.json index c0783f6b..17b6f5d3 100644 --- a/packages/@emulators/cloudflare/package.json +++ b/packages/@emulators/cloudflare/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/cloudflare", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/core/package.json b/packages/@emulators/core/package.json index b2b651c4..76a26cac 100644 --- a/packages/@emulators/core/package.json +++ b/packages/@emulators/core/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/core", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/github/package.json b/packages/@emulators/github/package.json index c39ed67f..28a5aa5d 100644 --- a/packages/@emulators/github/package.json +++ b/packages/@emulators/github/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/github", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/gitlab/package.json b/packages/@emulators/gitlab/package.json index eb613ba8..7827ac64 100644 --- a/packages/@emulators/gitlab/package.json +++ b/packages/@emulators/gitlab/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/gitlab", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/google/package.json b/packages/@emulators/google/package.json index 7bb156f2..c156635e 100644 --- a/packages/@emulators/google/package.json +++ b/packages/@emulators/google/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/google", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/mcp/package.json b/packages/@emulators/mcp/package.json index 68b4b8b3..a6972c1d 100644 --- a/packages/@emulators/mcp/package.json +++ b/packages/@emulators/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/mcp", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/microsoft/package.json b/packages/@emulators/microsoft/package.json index 8ca1e6c9..49f722ca 100644 --- a/packages/@emulators/microsoft/package.json +++ b/packages/@emulators/microsoft/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/microsoft", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/mongoatlas/package.json b/packages/@emulators/mongoatlas/package.json index 111c7acd..2c5fc876 100644 --- a/packages/@emulators/mongoatlas/package.json +++ b/packages/@emulators/mongoatlas/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/mongoatlas", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/okta/package.json b/packages/@emulators/okta/package.json index b44af821..ef3d7e9a 100644 --- a/packages/@emulators/okta/package.json +++ b/packages/@emulators/okta/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/okta", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/posthog/package.json b/packages/@emulators/posthog/package.json index a2a6fb2b..3ef299f7 100644 --- a/packages/@emulators/posthog/package.json +++ b/packages/@emulators/posthog/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/posthog", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/resend/package.json b/packages/@emulators/resend/package.json index 0eaa5a87..92250444 100644 --- a/packages/@emulators/resend/package.json +++ b/packages/@emulators/resend/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/resend", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/slack/package.json b/packages/@emulators/slack/package.json index 62f0999b..3aed9d01 100644 --- a/packages/@emulators/slack/package.json +++ b/packages/@emulators/slack/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/slack", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/spotify/package.json b/packages/@emulators/spotify/package.json index b9f4b037..5c9f38cb 100644 --- a/packages/@emulators/spotify/package.json +++ b/packages/@emulators/spotify/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/spotify", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/stripe/package.json b/packages/@emulators/stripe/package.json index cf01e905..10d06c26 100644 --- a/packages/@emulators/stripe/package.json +++ b/packages/@emulators/stripe/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/stripe", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/vercel/package.json b/packages/@emulators/vercel/package.json index 02029b27..42423b09 100644 --- a/packages/@emulators/vercel/package.json +++ b/packages/@emulators/vercel/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/vercel", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/workos/package.json b/packages/@emulators/workos/package.json index 5b6effda..f393a079 100644 --- a/packages/@emulators/workos/package.json +++ b/packages/@emulators/workos/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/workos", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/@emulators/x/package.json b/packages/@emulators/x/package.json index d231fd65..365f3626 100644 --- a/packages/@emulators/x/package.json +++ b/packages/@emulators/x/package.json @@ -1,6 +1,6 @@ { "name": "@emulators/x", - "version": "0.9.1", + "version": "0.10.0", "license": "Apache-2.0", "type": "module", "main": "./dist/index.js", diff --git a/packages/emulate/package.json b/packages/emulate/package.json index ef296eca..68994c7b 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -1,6 +1,6 @@ { "name": "@executor-js/emulate", - "version": "0.9.1", + "version": "0.10.0", "description": "Local drop-in replacement services for CI and no-network sandboxes", "license": "Apache-2.0", "type": "module", From 5fcaa1774884f71c1872580c52c53efeab8ab867 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:50:34 -0700 Subject: [PATCH 3/3] Format faults.ts --- packages/@emulators/core/src/faults.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/@emulators/core/src/faults.ts b/packages/@emulators/core/src/faults.ts index 2fef0560..1fbbe79b 100644 --- a/packages/@emulators/core/src/faults.ts +++ b/packages/@emulators/core/src/faults.ts @@ -87,10 +87,7 @@ export class FaultRegistry { } } -export function createFaultMiddleware( - faults: FaultRegistry, - manifest: ServiceManifest, -): MiddlewareHandler { +export function createFaultMiddleware(faults: FaultRegistry, manifest: ServiceManifest): MiddlewareHandler { const operations = manifest.specs.flatMap((spec) => spec.operations ?? []); return async (c, next) => { @@ -123,10 +120,9 @@ export function createFaultMiddleware( }; } -export function faultLedgerFields(c: { get: (key: "fault") => FaultLedgerMarker | undefined }): Pick< - LedgerEntry, - "faulted" | "faultId" -> { +export function faultLedgerFields(c: { + get: (key: "fault") => FaultLedgerMarker | undefined; +}): Pick { const fault = c.get("fault"); return fault ? { faulted: true, faultId: fault.faultId } : {}; } @@ -148,7 +144,11 @@ function validateFaultInput(input: FaultArmInput): void { throw new Error("Fault response.status must be an HTTP status code."); } if (input.response.headers !== undefined) { - if (!input.response.headers || typeof input.response.headers !== "object" || Array.isArray(input.response.headers)) { + if ( + !input.response.headers || + typeof input.response.headers !== "object" || + Array.isArray(input.response.headers) + ) { throw new Error("Fault response.headers must be an object."); } for (const [key, value] of Object.entries(input.response.headers)) { @@ -180,7 +180,11 @@ function matchesFault(fault: ArmedFault, request: FaultRequest): boolean { return true; } -function resolveOperation(operations: OperationCoverage[], method: string, path: string): OperationCoverage | undefined { +function resolveOperation( + operations: OperationCoverage[], + method: string, + path: string, +): OperationCoverage | undefined { return operations.find((operation) => { if (!operation.method || !operation.path) return false; if (operation.method.toUpperCase() !== method.toUpperCase()) return false;