@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.
- package/dist/components/index.d.ts +1 -1
- package/dist/components/replayFake.d.ts +28 -4
- package/dist/components/replayFake.js +84 -61
- package/package.json +1 -1
- package/src/components/index.ts +2 -0
- package/src/components/replayFake-handler.test.ts +217 -0
- package/src/components/replayFake-record.test.ts +42 -0
- package/src/components/replayFake.ts +116 -65
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
})
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
})
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
985
|
-
|
|
986
|
-
|
|
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
|
}
|