@specific.dev/spectest 0.100.0 → 0.101.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,6 @@ export { github, type GithubOptions } from "./github.js";
13
13
  export { apple, type AppleOptions } from "./apple.js";
14
14
  export { microsoft, type MicrosoftOptions } from "./microsoft.js";
15
15
  export { okta, type OktaOptions } from "./okta.js";
16
- export { replayFake, type ReplayFakeOptions, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
16
+ export { replayFake, type ReplayFakeOptions, type ReplayFakeContext, type ReplayHandler, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
17
17
  export { electron, type ElectronOptions } from "./electron.js";
18
18
  export { android, type AndroidOptions } from "./android.js";
@@ -1,4 +1,4 @@
1
- import type { FakeDefinition } from "../index.js";
1
+ import type { FakeContext, FakeDefinition } from "../index.js";
2
2
  import { type ReplayKey } from "./replay-key.js";
3
3
  export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
4
4
  /** Match one request dimension. A bare string is an exact match; the
@@ -54,7 +54,15 @@ export interface AwsSigV4Sign {
54
54
  sessionToken?: string;
55
55
  }
56
56
  export type SignConfig = AwsSigV4Sign;
57
- export interface ReplayFakeOptions {
57
+ /** A normal fake context with explicit delegation to recording/replay. */
58
+ export interface ReplayFakeContext extends FakeContext {
59
+ /** Record against the real upstream in record mode, or consume a matching
60
+ * cassette interaction in replay mode. A replay miss returns 599; there
61
+ * is no fallback to scripted logic. Inspect a body's clone before calling. */
62
+ replay(request: Request): Promise<Response>;
63
+ }
64
+ export type ReplayHandler<S> = (request: Request, state: S, ctx: ReplayFakeContext) => Response | Promise<Response>;
65
+ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unknown> = {}> {
58
66
  /** Stable name — the `ctx.fakes` key. */
59
67
  name: string;
60
68
  /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
@@ -87,6 +95,19 @@ export interface ReplayFakeOptions {
87
95
  /** `"auto"` (default) records under eval and replays under test;
88
96
  * `"record"` / `"replay"` force a mode. */
89
97
  mode?: "auto" | "record" | "replay";
98
+ /** Custom state, with the same lifecycle as a normal fake's state. Kept
99
+ * separate from cassette internals; helpers and handler see only S. */
100
+ state?: () => S | Promise<S>;
101
+ /** Custom helpers exposed alongside replay/dump/recorded/count. Those
102
+ * names are reserved. Loading a cassette does not change custom state. */
103
+ helpers?: (args: {
104
+ name: string;
105
+ state: S;
106
+ ctx: FakeContext;
107
+ }) => H | Promise<H>;
108
+ /** Decide whether to answer in code or call ctx.replay(request). Without
109
+ * a handler, every request delegates. Only delegated requests record. */
110
+ handler?: ReplayHandler<S>;
90
111
  }
91
112
  export interface CassetteRequest {
92
113
  method: string;
@@ -144,8 +165,9 @@ export interface ReplaySummary {
144
165
  interactions: number;
145
166
  }
146
167
  export interface ReplayHelpers extends Record<string, unknown> {
147
- /** Load a cassette into THIS fork's replay set and replay from it,
168
+ /** Load a cassette into THIS fork's replay set for delegated requests,
148
169
  * replacing whatever was loaded before and resetting the replay cursor.
170
+ * Does not change custom state or bypass a custom handler.
149
171
  * Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
150
172
  * path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
151
173
  * must stay under `spectest/tests/`) or a cassette object; a missing file
@@ -161,4 +183,6 @@ export interface ReplayHelpers extends Record<string, unknown> {
161
183
  /** Count of interactions captured this record session. */
162
184
  count(): number;
163
185
  }
164
- export declare function replayFake(opts: ReplayFakeOptions): FakeDefinition<CassetteState, ReplayHelpers>;
186
+ export declare function replayFake<S = undefined, H extends Record<string, unknown> = {}>(opts: ReplayFakeOptions<S, H>): FakeDefinition<CassetteState & {
187
+ custom: S;
188
+ }, ReplayHelpers & H>;
@@ -14,7 +14,7 @@
14
14
  // - REPLAY (hermetic): the request is matched against the cassette the
15
15
  // test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
16
16
  // recorded response is returned. No network. Nothing is auto-loaded:
17
- // a test that loads no cassette gets a fail-loud 599 on every request.
17
+ // a test that loads no cassette gets a fail-loud 599 on every delegated request.
18
18
  // This is what runs under `spectest test`.
19
19
  // - RECORD (MITM): the request is forwarded to the REAL host and the
20
20
  // request/response pair is captured (decoded, redacted), and the real
@@ -35,6 +35,8 @@
35
35
  // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
36
  // (the default) replays under test and records during manual use — no new
37
37
  // control-plane mode flag. `mode: "record" | "replay"` overrides it.
38
+ // An optional custom handler can return scripted responses or explicitly
39
+ // delegate with ctx.replay(request). Only delegated traffic uses this engine.
38
40
  //
39
41
  // Credential brokering (per-fake): `inject` rules set
40
42
  // headers on the egress forward — overwriting whatever the app sent — so
@@ -644,6 +646,9 @@ export function replayFake(opts) {
644
646
  }
645
647
  // ── handler ─────────────────────────────────────────────────────────
646
648
  const handler = async (req, state, _ctx) => {
649
+ if (req.bodyUsed) {
650
+ throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
651
+ }
647
652
  const url = new URL(req.url);
648
653
  const bodyBytes = req.method === "GET" || req.method === "HEAD"
649
654
  ? new Uint8Array(0)
@@ -668,71 +673,89 @@ export function replayFake(opts) {
668
673
  return replay(reqLike, state);
669
674
  return record(req, url, reqLike, bodyBytes, state);
670
675
  };
676
+ const replayHelpers = (state) => ({
677
+ replay(file) {
678
+ let loaded;
679
+ let label;
680
+ if (file && typeof file === "object") {
681
+ loaded = coerceCassette(file, opts.name, host);
682
+ label = file.fake || "(inline)";
683
+ }
684
+ else if (typeof file === "string" && file.length > 0) {
685
+ label = file;
686
+ loaded = readCassetteFile(opts.name, file, host);
687
+ }
688
+ else {
689
+ throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
690
+ `(e.g. "recordings/${opts.name}.json").`);
691
+ }
692
+ // Build/validate before replacing live state: a bad key/cassette
693
+ // must leave the previous cassette and consumption cursors intact.
694
+ const index = new Map();
695
+ for (const [i, interaction] of loaded.interactions.entries()) {
696
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
697
+ const queue = index.get(sig);
698
+ if (queue)
699
+ queue.push(i);
700
+ else
701
+ index.set(sig, [i]);
702
+ }
703
+ // Mutate the live state object (the handler reads the same
704
+ // reference); reset the cursor so replay starts from the top.
705
+ state.cassette = loaded;
706
+ state.index = index;
707
+ state.cursor = new Map();
708
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
709
+ },
710
+ dump() {
711
+ return structuredClone({
712
+ version: 1,
713
+ fake: opts.name,
714
+ host,
715
+ interactions: [...state.cassette.interactions, ...state.recorded],
716
+ });
717
+ },
718
+ recorded() {
719
+ return structuredClone(state.recorded);
720
+ },
721
+ count() {
722
+ return state.recorded.length;
723
+ },
724
+ });
725
+ const initialState = (custom) => ({
726
+ cassette: emptyCassette(opts.name, host),
727
+ index: new Map(),
728
+ cursor: new Map(),
729
+ recorded: [],
730
+ dirty: false,
731
+ custom,
732
+ });
671
733
  const def = {
672
734
  name: opts.name,
673
- // Fake = real: we answer for the real host on :80 and :443.
674
735
  hostnames: [host],
675
736
  secretRefs,
676
- // Cassettes are NOT auto-loaded — a fork starts with an empty replay
677
- // set, so a test that loads nothing fails loud (599). Tests call
678
- // `replay(...)` to populate it.
679
- state: () => ({
680
- cassette: emptyCassette(opts.name, host),
681
- index: new Map(),
682
- cursor: new Map(),
683
- recorded: [],
684
- dirty: false,
685
- }),
686
- handler,
687
- helpers: ({ state }) => ({
688
- replay(file) {
689
- let loaded;
690
- let label;
691
- if (file && typeof file === "object") {
692
- loaded = coerceCassette(file, opts.name, host);
693
- label = file.fake || "(inline)";
694
- }
695
- else if (typeof file === "string" && file.length > 0) {
696
- label = file;
697
- loaded = readCassetteFile(opts.name, file, host);
698
- }
699
- else {
700
- throw new Error(`replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
701
- `(e.g. "recordings/${opts.name}.json").`);
702
- }
703
- // Build/validate before replacing live state: a bad key/cassette
704
- // must leave the previous cassette and consumption cursors intact.
705
- const index = new Map();
706
- for (const [i, interaction] of loaded.interactions.entries()) {
707
- const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
708
- const queue = index.get(sig);
709
- if (queue)
710
- queue.push(i);
711
- else
712
- index.set(sig, [i]);
737
+ state: () => opts.state
738
+ ? Promise.resolve(opts.state()).then(initialState)
739
+ : initialState(undefined),
740
+ handler: (request, state, ctx) => opts.handler
741
+ ? opts.handler(request, state.custom, {
742
+ ...ctx,
743
+ replay: (delegated) => handler(delegated, state, ctx),
744
+ })
745
+ : handler(request, state, ctx),
746
+ helpers: ({ name, state, ctx }) => {
747
+ const builtins = replayHelpers(state);
748
+ if (!opts.helpers)
749
+ return builtins;
750
+ return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
751
+ for (const key of Object.keys(builtins)) {
752
+ if (key in custom) {
753
+ throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
754
+ }
713
755
  }
714
- // Mutate the live state object (the handler reads the same
715
- // reference); reset the cursor so replay starts from the top.
716
- state.cassette = loaded;
717
- state.index = index;
718
- state.cursor = new Map();
719
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
720
- },
721
- dump() {
722
- return structuredClone({
723
- version: 1,
724
- fake: opts.name,
725
- host,
726
- interactions: [...state.cassette.interactions, ...state.recorded],
727
- });
728
- },
729
- recorded() {
730
- return structuredClone(state.recorded);
731
- },
732
- count() {
733
- return state.recorded.length;
734
- },
735
- }),
756
+ return { ...custom, ...builtins };
757
+ });
758
+ },
736
759
  };
737
760
  return def;
738
761
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.100.0",
3
+ "version": "0.101.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -85,6 +85,8 @@ export { okta, type OktaOptions } from "./okta.js";
85
85
  export {
86
86
  replayFake,
87
87
  type ReplayFakeOptions,
88
+ type ReplayFakeContext,
89
+ type ReplayHandler,
88
90
  type ReplayKey,
89
91
  type ReplayRequest,
90
92
  type MatchKey,
@@ -0,0 +1,217 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { defineEnvironment, type FakeContext } from "../index.js";
7
+ import { replayFake, type Cassette, type ReplayFakeContext, type ReplayHandler } from "./index.js";
8
+
9
+ const host = "api.example.test";
10
+ const context = {} as FakeContext;
11
+ const fixture: Cassette = {
12
+ version: 1, fake: "mixed", host,
13
+ interactions: ["one", "two"].map((task) => ({
14
+ request: {
15
+ method: "POST", path: "/messages", query: {}, headers: {},
16
+ body: JSON.stringify({ task }), bodyEncoding: "utf8",
17
+ },
18
+ response: { status: 200, headers: {}, body: `recorded-${task}`, bodyEncoding: "utf8" },
19
+ })),
20
+ };
21
+ const request = (task = "one", path = "/messages") => new Request(`https://${host}${path}`, {
22
+ method: "POST", body: JSON.stringify({ task }),
23
+ });
24
+
25
+ function mixedFake() {
26
+ return replayFake({
27
+ name: "mixed", host, mode: "replay",
28
+ state: () => ({ useRecordings: false, reply: "scripted", seen: [] as string[] }),
29
+ helpers: ({ state }) => ({
30
+ useRecordings(enabled = true) { state.useRecordings = enabled; },
31
+ reply(text: string) { state.reply = text; },
32
+ seen() { return [...state.seen]; },
33
+ }),
34
+ async handler(req, state, ctx) {
35
+ const body = await req.clone().json() as { task: string };
36
+ state.seen.push(body.task);
37
+ if (state.useRecordings && new URL(req.url).pathname === "/messages") {
38
+ return ctx.replay(req);
39
+ }
40
+ return new Response(state.reply);
41
+ },
42
+ });
43
+ }
44
+
45
+ describe("scripted behavior on replay fakes", () => {
46
+ test("loading only loads; custom helpers select behavior and misses never fall back", async () => {
47
+ const fake = mixedFake();
48
+ const state = await fake.state!();
49
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx: context });
50
+ const send = (task?: string, path?: string) => fake.handler(request(task, path), state, context);
51
+
52
+ expect(await (await send()).text()).toBe("scripted");
53
+ helpers.reply("configured");
54
+ helpers.replay(fixture);
55
+ expect(await (await send()).text()).toBe("configured");
56
+ helpers.useRecordings();
57
+ expect((await send("missing")).status).toBe(599);
58
+ // The same fake mixes scripted and replayed endpoints concurrently.
59
+ expect(await Promise.all([
60
+ send("two"), send("one", "/transcription"), send("one"),
61
+ ].map(async (response) => (await response).text()))).toEqual([
62
+ "recorded-two", "configured", "recorded-one",
63
+ ]);
64
+ expect((await send()).status).toBe(599);
65
+ helpers.useRecordings(false);
66
+ expect(await (await send()).text()).toBe("configured");
67
+ expect(helpers.count()).toBe(0);
68
+ expect(helpers.recorded()).toEqual([]);
69
+ expect(helpers.seen()).toHaveLength(8);
70
+ });
71
+
72
+ test("delegating without a cassette fails even when scripted responses are available", async () => {
73
+ const fake = mixedFake();
74
+ const state = await fake.state!();
75
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx: context });
76
+ helpers.useRecordings();
77
+ expect((await fake.handler(request(), state, context)).status).toBe(599);
78
+ });
79
+
80
+ test("custom state and cassette cursors survive cloning and siblings remain independent", async () => {
81
+ const fake = mixedFake();
82
+ const parent = await fake.state!();
83
+ const parentHelpers = await fake.helpers!({ name: "mixed", state: parent, ctx: context });
84
+ parentHelpers.replay(fixture);
85
+ parentHelpers.useRecordings();
86
+ await fake.handler(request("one"), parent, context);
87
+ // Model the state inherited by forks, including custom state and cursors.
88
+ const left = structuredClone(parent);
89
+ const right = structuredClone(parent);
90
+ const leftHelpers = await fake.helpers!({ name: "mixed", state: left, ctx: context });
91
+ leftHelpers.reply("left only");
92
+ expect((await fake.handler(request("one"), right, context)).status).toBe(599);
93
+ expect(await (await fake.handler(request("two"), left, context)).text()).toBe("recorded-two");
94
+ expect(await (await fake.handler(request("two"), right, context)).text()).toBe("recorded-two");
95
+ leftHelpers.useRecordings(false);
96
+ expect(right.custom.useRecordings).toBe(true);
97
+ expect(right.custom.reply).toBe("scripted");
98
+ expect(parent.cursor.size).toBe(1);
99
+ });
100
+
101
+ test("async factories receive custom state and the normal fake context", async () => {
102
+ const ctx = { dnsName: async () => {} } as unknown as FakeContext;
103
+ const fake = replayFake({
104
+ name: "mixed", host, mode: "replay",
105
+ state: async () => ({ reply: "async" }),
106
+ helpers: async ({ state, name, ctx: received }) => {
107
+ expect(name).toBe("mixed");
108
+ expect(received).toBe(ctx);
109
+ return { reply: (text: string) => { state.reply = text; } };
110
+ },
111
+ handler: (_req, state, received) => {
112
+ expect(received.dnsName).toBe(ctx.dnsName);
113
+ expect(typeof received.replay).toBe("function");
114
+ expect("replay" in ctx).toBe(false);
115
+ return new Response(state.reply);
116
+ },
117
+ });
118
+ const state = await fake.state!();
119
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx });
120
+ helpers.reply("updated");
121
+ expect(await (await fake.handler(request(), state, ctx)).text()).toBe("updated");
122
+ });
123
+
124
+ test.each(["replay", "dump", "recorded", "count"])("rejects reserved helper name %s", async (name) => {
125
+ const fake = replayFake({ name: "mixed", host, helpers: () => ({ [name]: () => {} }) });
126
+ const state = await fake.state!();
127
+ await expect(Promise.resolve().then(() => fake.helpers!({ name: "mixed", state, ctx: context })))
128
+ .rejects.toThrow(`custom helper "${name}" is reserved`);
129
+ });
130
+
131
+ test("handler-only customization needs no state or helpers", async () => {
132
+ const fake = replayFake({
133
+ name: "mixed", host, mode: "replay",
134
+ handler(req, state, ctx) {
135
+ expect(state).toBeUndefined();
136
+ return ctx.replay(req);
137
+ },
138
+ });
139
+ const state = await fake.state!();
140
+ const helpers = await fake.helpers!({ name: "mixed", state, ctx: context });
141
+ helpers.replay(fixture);
142
+ expect(await (await fake.handler(request(), state, context)).text()).toBe("recorded-one");
143
+ });
144
+
145
+ test("a consumed body reports how to inspect safely before delegation", async () => {
146
+ const fake = replayFake({
147
+ name: "mixed", host, mode: "replay",
148
+ async handler(req, _state, ctx) {
149
+ await req.json();
150
+ return ctx.replay(req);
151
+ },
152
+ });
153
+ const state = await fake.state!();
154
+ await expect(fake.handler(request(), state, context)).rejects.toThrow("inspect request.clone()");
155
+ });
156
+
157
+ test("public state, context, and helper types stay inferred through defineEnvironment", async () => {
158
+ const dir = await mkdtemp(join(tmpdir(), "spectest-replay-types-"));
159
+ try {
160
+ const config = join(dir, "tsconfig.json");
161
+ await writeFile(config, JSON.stringify({
162
+ extends: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
163
+ compilerOptions: {
164
+ noEmit: true,
165
+ typeRoots: [fileURLToPath(new URL("../../node_modules/@types", import.meta.url))],
166
+ },
167
+ include: [fileURLToPath(import.meta.url)],
168
+ exclude: [],
169
+ }));
170
+ const child = Bun.spawn([process.execPath, fileURLToPath(import.meta.resolve("typescript/bin/tsc")), "-p", config], {
171
+ stdout: "pipe", stderr: "pipe",
172
+ });
173
+ const [exit, stdout, stderr] = await Promise.all([
174
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
175
+ ]);
176
+ expect(exit, stdout + stderr).toBe(0);
177
+ } finally {
178
+ await rm(dir, { recursive: true, force: true });
179
+ }
180
+ }, 30_000);
181
+ });
182
+
183
+ // Checked with tsc as well as the runtime tests: inference must survive the
184
+ // public components export and defineEnvironment's wrapped helper surface.
185
+ function checkPublicTypes() {
186
+ const fake = replayFake({
187
+ name: "typed", host,
188
+ state: () => ({ enabled: false }),
189
+ helpers: ({ state }) => ({
190
+ enable(value: boolean) {
191
+ state.enabled = value;
192
+ // @ts-expect-error custom state is inferred, not any
193
+ state.missing;
194
+ },
195
+ }),
196
+ handler(req, state, ctx) {
197
+ const enabled: boolean = state.enabled;
198
+ const typedContext: ReplayFakeContext = ctx;
199
+ // @ts-expect-error internal cassette state is not exposed to the handler
200
+ state.cassette;
201
+ return enabled ? typedContext.replay(req) : new Response("scripted");
202
+ },
203
+ });
204
+ const env = defineEnvironment({ name: "typed", services: {}, fakes: { typed: fake } });
205
+ env.test("types", (ctx) => {
206
+ ctx.fakes.typed.enable(true);
207
+ ctx.fakes.typed.replay(fixture);
208
+ ctx.fakes.typed.dump();
209
+ // @ts-expect-error helper arguments retain their inferred types
210
+ ctx.fakes.typed.enable("yes");
211
+ // @ts-expect-error unknown helpers are not callable
212
+ ctx.fakes.typed.missing();
213
+ });
214
+ const handler: ReplayHandler<{ enabled: boolean }> = (req, state, ctx) =>
215
+ state.enabled ? ctx.replay(req) : new Response("scripted");
216
+ return handler;
217
+ }
@@ -95,6 +95,48 @@ test("recorded headers, redaction, and exact body bytes survive record → repla
95
95
  }
96
96
  }
97
97
  assert.equal(upstreamHeaders.length, 3); // Replay never forwards.
98
+
99
+ // The custom handler delegates through the same auto-mode pipeline.
100
+ // Scripted requests must neither forward nor enter the cassette.
101
+ const { startRecording, stopRecording } = await import(${moduleUrl("../recorder.ts")});
102
+ const mixed = replayFake({
103
+ ...options,
104
+ state: () => ({ useRecordings: false }),
105
+ helpers: ({ state }) => ({ useRecordings() { state.useRecordings = true; } }),
106
+ async handler(request, state, ctx) {
107
+ const body = await request.clone().text();
108
+ return state.useRecordings && body !== "scripted"
109
+ ? ctx.replay(request)
110
+ : new Response("scripted response");
111
+ },
112
+ });
113
+ const mixedState = await mixed.state();
114
+ const mixedHelpers = await mixed.helpers({ name: "test", state: mixedState, ctx: {} });
115
+ const send = (body) => mixed.handler(new Request("https://api.example.test/messages", {
116
+ method: "POST", body,
117
+ }), mixedState, {});
118
+ assert.equal(await (await send("delegated")).text(), "scripted response");
119
+ assert.equal(upstreamHeaders.length, 3);
120
+ mixedHelpers.useRecordings();
121
+ assert.equal(await (await send("scripted")).text(), "scripted response");
122
+ assert.equal(await (await send("delegated")).text(), "response");
123
+ assert.equal(upstreamHeaders.length, 4);
124
+ assert.equal(upstreamHeaders[3].authorization, "Bearer recording-secret");
125
+ assert.equal(mixedHelpers.count(), 1);
126
+ const mixedCassette = mixedHelpers.dump();
127
+ assert.equal(mixedCassette.interactions[0].request.body, "delegated");
128
+ mixedHelpers.replay(mixedCassette);
129
+ startRecording(); // Enter a test: auto now replays instead of forwarding.
130
+ try {
131
+ assert.equal(await (await send("scripted")).text(), "scripted response");
132
+ assert.equal(await (await send("delegated")).text(), "response");
133
+ assert.equal((await send("delegated")).status, 599);
134
+ assert.equal((await send("unrecorded")).status, 599);
135
+ assert.equal(mixedHelpers.count(), 1);
136
+ assert.equal(upstreamHeaders.length, 4);
137
+ } finally {
138
+ stopRecording();
139
+ }
98
140
  });
99
141
  `;
100
142
  const dir = await mkdtemp(join(tmpdir(), "spectest-replay-record-"));
@@ -14,7 +14,7 @@
14
14
  // - REPLAY (hermetic): the request is matched against the cassette the
15
15
  // test EXPLICITLY loaded (`ctx.fakes.<name>.replay(file)`) and the
16
16
  // recorded response is returned. No network. Nothing is auto-loaded:
17
- // a test that loads no cassette gets a fail-loud 599 on every request.
17
+ // a test that loads no cassette gets a fail-loud 599 on every delegated request.
18
18
  // This is what runs under `spectest test`.
19
19
  // - RECORD (MITM): the request is forwarded to the REAL host and the
20
20
  // request/response pair is captured (decoded, redacted), and the real
@@ -35,6 +35,8 @@
35
35
  // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
36
  // (the default) replays under test and records during manual use — no new
37
37
  // control-plane mode flag. `mode: "record" | "replay"` overrides it.
38
+ // An optional custom handler can return scripted responses or explicitly
39
+ // delegate with ctx.replay(request). Only delegated traffic uses this engine.
38
40
  //
39
41
  // Credential brokering (per-fake): `inject` rules set
40
42
  // headers on the egress forward — overwriting whatever the app sent — so
@@ -133,7 +135,24 @@ export interface AwsSigV4Sign {
133
135
 
134
136
  export type SignConfig = AwsSigV4Sign;
135
137
 
136
- export interface ReplayFakeOptions {
138
+ /** A normal fake context with explicit delegation to recording/replay. */
139
+ export interface ReplayFakeContext extends FakeContext {
140
+ /** Record against the real upstream in record mode, or consume a matching
141
+ * cassette interaction in replay mode. A replay miss returns 599; there
142
+ * is no fallback to scripted logic. Inspect a body's clone before calling. */
143
+ replay(request: Request): Promise<Response>;
144
+ }
145
+
146
+ export type ReplayHandler<S> = (
147
+ request: Request,
148
+ state: S,
149
+ ctx: ReplayFakeContext,
150
+ ) => Response | Promise<Response>;
151
+
152
+ export interface ReplayFakeOptions<
153
+ S = undefined,
154
+ H extends Record<string, unknown> = {},
155
+ > {
137
156
  /** Stable name — the `ctx.fakes` key. */
138
157
  name: string;
139
158
  /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
@@ -166,6 +185,15 @@ export interface ReplayFakeOptions {
166
185
  /** `"auto"` (default) records under eval and replays under test;
167
186
  * `"record"` / `"replay"` force a mode. */
168
187
  mode?: "auto" | "record" | "replay";
188
+ /** Custom state, with the same lifecycle as a normal fake's state. Kept
189
+ * separate from cassette internals; helpers and handler see only S. */
190
+ state?: () => S | Promise<S>;
191
+ /** Custom helpers exposed alongside replay/dump/recorded/count. Those
192
+ * names are reserved. Loading a cassette does not change custom state. */
193
+ helpers?: (args: { name: string; state: S; ctx: FakeContext }) => H | Promise<H>;
194
+ /** Decide whether to answer in code or call ctx.replay(request). Without
195
+ * a handler, every request delegates. Only delegated requests record. */
196
+ handler?: ReplayHandler<S>;
169
197
  }
170
198
 
171
199
  export interface CassetteRequest {
@@ -230,8 +258,9 @@ export interface ReplaySummary {
230
258
  }
231
259
 
232
260
  export interface ReplayHelpers extends Record<string, unknown> {
233
- /** Load a cassette into THIS fork's replay set and replay from it,
261
+ /** Load a cassette into THIS fork's replay set for delegated requests,
234
262
  * replacing whatever was loaded before and resetting the replay cursor.
263
+ * Does not change custom state or bypass a custom handler.
235
264
  * Cassettes are NOT auto-loaded — call this at the top of a test. Pass a
236
265
  * path relative to `spectest/tests/` (e.g. `"recordings/stripe.json"`; it
237
266
  * must stay under `spectest/tests/`) or a cassette object; a missing file
@@ -627,9 +656,9 @@ function forwardToRealUpstream(args: {
627
656
  // replayFake
628
657
  // ────────────────────────────────────────────────────────────────────────
629
658
 
630
- export function replayFake(
631
- opts: ReplayFakeOptions,
632
- ): FakeDefinition<CassetteState, ReplayHelpers> {
659
+ export function replayFake<S = undefined, H extends Record<string, unknown> = {}>(
660
+ opts: ReplayFakeOptions<S, H>,
661
+ ): FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> {
633
662
  if (!opts.name) throw new Error("replayFake: `name` is required");
634
663
  if (!opts.host || typeof opts.host !== "string") {
635
664
  throw new Error(`replayFake(${opts.name}): "host" (the real hostname) is required`);
@@ -914,6 +943,9 @@ export function replayFake(
914
943
  state: CassetteState,
915
944
  _ctx: FakeContext,
916
945
  ): Promise<Response> => {
946
+ if (req.bodyUsed) {
947
+ throw new Error(`replayFake(${opts.name}): cannot delegate a consumed request body; inspect request.clone() before calling ctx.replay(request)`);
948
+ }
917
949
  const url = new URL(req.url);
918
950
  const bodyBytes =
919
951
  req.method === "GET" || req.method === "HEAD"
@@ -940,69 +972,88 @@ export function replayFake(
940
972
  return record(req, url, reqLike, bodyBytes, state);
941
973
  };
942
974
 
943
- const def: FakeDefinition<CassetteState, ReplayHelpers> = {
975
+ const replayHelpers = (state: CassetteState): ReplayHelpers => ({
976
+ replay(file: string | Cassette): ReplaySummary {
977
+ let loaded: Cassette;
978
+ let label: string;
979
+ if (file && typeof file === "object") {
980
+ loaded = coerceCassette(file, opts.name, host);
981
+ label = file.fake || "(inline)";
982
+ } else if (typeof file === "string" && file.length > 0) {
983
+ label = file;
984
+ loaded = readCassetteFile(opts.name, file, host);
985
+ } else {
986
+ throw new Error(
987
+ `replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
988
+ `(e.g. "recordings/${opts.name}.json").`,
989
+ );
990
+ }
991
+ // Build/validate before replacing live state: a bad key/cassette
992
+ // must leave the previous cassette and consumption cursors intact.
993
+ const index = new Map<string, number[]>();
994
+ for (const [i, interaction] of loaded.interactions.entries()) {
995
+ const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
996
+ const queue = index.get(sig);
997
+ if (queue) queue.push(i);
998
+ else index.set(sig, [i]);
999
+ }
1000
+ // Mutate the live state object (the handler reads the same
1001
+ // reference); reset the cursor so replay starts from the top.
1002
+ state.cassette = loaded;
1003
+ state.index = index;
1004
+ state.cursor = new Map();
1005
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
1006
+ },
1007
+ dump(): Cassette {
1008
+ return structuredClone({
1009
+ version: 1,
1010
+ fake: opts.name,
1011
+ host,
1012
+ interactions: [...state.cassette.interactions, ...state.recorded],
1013
+ });
1014
+ },
1015
+ recorded(): CassetteInteraction[] {
1016
+ return structuredClone(state.recorded);
1017
+ },
1018
+ count(): number {
1019
+ return state.recorded.length;
1020
+ },
1021
+ });
1022
+
1023
+ const initialState = (custom: S): CassetteState & { custom: S } => ({
1024
+ cassette: emptyCassette(opts.name, host),
1025
+ index: new Map(),
1026
+ cursor: new Map(),
1027
+ recorded: [],
1028
+ dirty: false,
1029
+ custom,
1030
+ });
1031
+
1032
+ const def: FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> = {
944
1033
  name: opts.name,
945
- // Fake = real: we answer for the real host on :80 and :443.
946
1034
  hostnames: [host],
947
1035
  secretRefs,
948
- // Cassettes are NOT auto-loaded — a fork starts with an empty replay
949
- // set, so a test that loads nothing fails loud (599). Tests call
950
- // `replay(...)` to populate it.
951
- state: (): CassetteState => ({
952
- cassette: emptyCassette(opts.name, host),
953
- index: new Map(),
954
- cursor: new Map(),
955
- recorded: [],
956
- dirty: false,
957
- }),
958
- handler,
959
- helpers: ({ state }): ReplayHelpers => ({
960
- replay(file: string | Cassette): ReplaySummary {
961
- let loaded: Cassette;
962
- let label: string;
963
- if (file && typeof file === "object") {
964
- loaded = coerceCassette(file, opts.name, host);
965
- label = file.fake || "(inline)";
966
- } else if (typeof file === "string" && file.length > 0) {
967
- label = file;
968
- loaded = readCassetteFile(opts.name, file, host);
969
- } else {
970
- throw new Error(
971
- `replayFake(${opts.name}): replay(...) needs a cassette path under spectest/tests/ ` +
972
- `(e.g. "recordings/${opts.name}.json").`,
973
- );
974
- }
975
- // Build/validate before replacing live state: a bad key/cassette
976
- // must leave the previous cassette and consumption cursors intact.
977
- const index = new Map<string, number[]>();
978
- for (const [i, interaction] of loaded.interactions.entries()) {
979
- const sig = signature(interaction.request, `${JSON.stringify(label)} interaction #${i + 1}`);
980
- const queue = index.get(sig);
981
- if (queue) queue.push(i);
982
- else index.set(sig, [i]);
1036
+ state: () => opts.state
1037
+ ? Promise.resolve(opts.state()).then(initialState)
1038
+ : initialState(undefined as S),
1039
+ handler: (request, state, ctx) => opts.handler
1040
+ ? opts.handler(request, state.custom, {
1041
+ ...ctx,
1042
+ replay: (delegated) => handler(delegated, state, ctx),
1043
+ })
1044
+ : handler(request, state, ctx),
1045
+ helpers: ({ name, state, ctx }) => {
1046
+ const builtins = replayHelpers(state);
1047
+ if (!opts.helpers) return builtins as ReplayHelpers & H;
1048
+ return Promise.resolve(opts.helpers({ name, state: state.custom, ctx })).then((custom) => {
1049
+ for (const key of Object.keys(builtins)) {
1050
+ if (key in custom) {
1051
+ throw new Error(`replayFake(${opts.name}): custom helper ${JSON.stringify(key)} is reserved for recording/replay; choose another name`);
1052
+ }
983
1053
  }
984
- // Mutate the live state object (the handler reads the same
985
- // reference); reset the cursor so replay starts from the top.
986
- state.cassette = loaded;
987
- state.index = index;
988
- state.cursor = new Map();
989
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
990
- },
991
- dump(): Cassette {
992
- return structuredClone({
993
- version: 1,
994
- fake: opts.name,
995
- host,
996
- interactions: [...state.cassette.interactions, ...state.recorded],
997
- });
998
- },
999
- recorded(): CassetteInteraction[] {
1000
- return structuredClone(state.recorded);
1001
- },
1002
- count(): number {
1003
- return state.recorded.length;
1004
- },
1005
- }),
1054
+ return { ...custom, ...builtins };
1055
+ });
1056
+ },
1006
1057
  };
1007
1058
  return def;
1008
1059
  }