@specific.dev/spectest 0.99.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/replay-key.d.ts +27 -0
- package/dist/components/replay-key.js +96 -0
- package/dist/components/replayFake.d.ts +40 -21
- package/dist/components/replayFake.js +119 -95
- package/package.json +1 -1
- package/src/components/index.ts +6 -1
- package/src/components/replay-key.ts +127 -0
- package/src/components/replayFake-handler.test.ts +217 -0
- package/src/components/replayFake-record.test.ts +156 -0
- package/src/components/replayFake.test.ts +254 -0
- package/src/components/replayFake.ts +161 -116
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { FakeContext } from "../index.js";
|
|
4
|
+
import { replayFake, type Cassette, type CassetteInteraction, type ReplayFakeOptions, type ReplayRequest, type ReplayKey, type MatchKey } from "./index.js";
|
|
5
|
+
|
|
6
|
+
const context = {} as FakeContext;
|
|
7
|
+
const host = "api.example.test";
|
|
8
|
+
const sha = (body: string | Uint8Array) => `sha256:${createHash("sha256").update(body).digest("hex")}`;
|
|
9
|
+
|
|
10
|
+
function interaction(body: string | Uint8Array, answer: string, path = "/messages"): CassetteInteraction {
|
|
11
|
+
return {
|
|
12
|
+
request: {
|
|
13
|
+
method: "POST", path, query: {}, headers: { "content-type": "application/json" },
|
|
14
|
+
body: typeof body === "string" ? body : Buffer.from(body).toString("base64"),
|
|
15
|
+
bodyEncoding: typeof body === "string" ? "utf8" : "base64",
|
|
16
|
+
bodyHash: sha(body),
|
|
17
|
+
},
|
|
18
|
+
response: { status: 200, headers: {}, body: answer, bodyEncoding: "utf8" },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function cassette(interactions: CassetteInteraction[]): Cassette {
|
|
23
|
+
return { version: 1, fake: "test", host, interactions };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function harness(options: Partial<ReplayFakeOptions> = {}) {
|
|
27
|
+
const fake = replayFake({ name: "test", host, mode: "replay", ...options });
|
|
28
|
+
const state = await fake.state!();
|
|
29
|
+
const helpers = await fake.helpers!({ name: "test", state, ctx: context });
|
|
30
|
+
return {
|
|
31
|
+
state, helpers,
|
|
32
|
+
async send(body: string | Uint8Array = "", path = "/messages", init: RequestInit = {}) {
|
|
33
|
+
return await fake.handler(new Request(`https://${host}${path}`, {
|
|
34
|
+
method: "POST", body: body as BodyInit, ...init,
|
|
35
|
+
}), state, context);
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("replay request identity", () => {
|
|
41
|
+
test("default matches 654 concurrent requests by body, independently of arrival order", async () => {
|
|
42
|
+
const h = await harness();
|
|
43
|
+
const calls = Array.from({ length: 654 }, (_, id) => interaction(JSON.stringify({ id }), `answer-${id}`));
|
|
44
|
+
h.helpers.replay(cassette(calls));
|
|
45
|
+
// A permutation visits every identity once in a different order.
|
|
46
|
+
const results = await Promise.all(calls.map(async (_, i) => {
|
|
47
|
+
const id = (i * 37) % calls.length;
|
|
48
|
+
const response = await h.send(JSON.stringify({ id }));
|
|
49
|
+
expect(response.status).toBe(200);
|
|
50
|
+
expect(await response.text()).toBe(`answer-${id}`);
|
|
51
|
+
}));
|
|
52
|
+
expect(results).toHaveLength(654);
|
|
53
|
+
expect(h.state.cursor.size).toBe(654);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("default uses method/path/normalized query/exact bytes, including old cassettes without hashes", async () => {
|
|
57
|
+
const h = await harness();
|
|
58
|
+
const it = interaction('{"a":1}', "ok");
|
|
59
|
+
delete it.request.bodyHash;
|
|
60
|
+
delete it.request.bodyEncoding;
|
|
61
|
+
it.request.query = { b: ["1", "2"], a: ["x"] };
|
|
62
|
+
h.helpers.replay(cassette([it]));
|
|
63
|
+
for (const [body, path, init] of [
|
|
64
|
+
['{ "a":1}', "/messages?a=x&b=2&b=1", {}],
|
|
65
|
+
['{"a":1}', "/other?a=x&b=2&b=1", {}],
|
|
66
|
+
['{"a":1}', "/messages?a=x&b=2&b=1", { method: "PUT" }],
|
|
67
|
+
['{"a":1}', "/messages?a=y&b=2&b=1", {}],
|
|
68
|
+
] as const) expect((await h.send(body, path, init)).status).toBe(599);
|
|
69
|
+
expect(await (await h.send('{"a":1}', "/messages?b=2&a=x&b=1")).text()).toBe("ok");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the existing committed HTTP cassette still replays with the new default", async () => {
|
|
73
|
+
const h = await harness();
|
|
74
|
+
const existing = await Bun.file(new URL("../../../examples/replay-fake/spectest/tests/recordings/httpbin.json", import.meta.url)).json() as Cassette;
|
|
75
|
+
h.helpers.replay(existing);
|
|
76
|
+
const response = await h.send("", "/get?hello=world", { method: "GET", body: null });
|
|
77
|
+
expect(response.status).toBe(200);
|
|
78
|
+
expect((await response.json()).args).toEqual({ hello: ["world"] });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("SigV4 query credentials stay outside default and custom identity", async () => {
|
|
82
|
+
for (const key of [undefined, (r: ReplayRequest) => ({ path: r.path, query: r.query })]) {
|
|
83
|
+
const h = await harness({ key, sign: { type: "awsSigv4", accessKeyId: "ACCESS", secretAccessKey: "SECRET" } });
|
|
84
|
+
h.helpers.replay(cassette([interaction("body", "ok")]));
|
|
85
|
+
expect(await (await h.send("body", "/messages?X-Amz-Signature=changing&X-Amz-Date=20261001")).text()).toBe("ok");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("Dentio keys separate model/tasks while ignoring a changing date", async () => {
|
|
90
|
+
let evaluations = 0;
|
|
91
|
+
const h = await harness({ key(request) {
|
|
92
|
+
evaluations++;
|
|
93
|
+
const body = request.json<{ system: { text: string }[] }>();
|
|
94
|
+
return { model: request.path, task: body.system[0].text.slice(0, 200) };
|
|
95
|
+
} });
|
|
96
|
+
const calls = Array.from({ length: 8 }, (_, i) => ({
|
|
97
|
+
task: `task-${i}`.padEnd(200, "."),
|
|
98
|
+
path: `/model/${i < 4 ? "haiku" : "sonnet"}/converse`,
|
|
99
|
+
}));
|
|
100
|
+
const body = (task: string, date: string) => JSON.stringify({ system: [{ text: `${task} Today: ${date}` }] });
|
|
101
|
+
h.helpers.replay(cassette(calls.map((call, i) => interaction(body(call.task, "2026-10-01"), i === 4 ? "Karies 16o" : `task-${i}`, call.path))));
|
|
102
|
+
expect(evaluations).toBe(8);
|
|
103
|
+
for (const i of [7, 2, 4, 0, 6, 1, 5, 3]) {
|
|
104
|
+
expect(await (await h.send(body(calls[i].task, "2026-10-02"), calls[i].path)).text()).toBe(i === 4 ? "Karies 16o" : `task-${i}`);
|
|
105
|
+
}
|
|
106
|
+
expect(evaluations).toBe(16); // No rescanning/recomputing cassette keys.
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("custom key is the entire identity, with no implicit HTTP dimensions", async () => {
|
|
110
|
+
const h = await harness({ key: request => request.json<{ task: string }>().task });
|
|
111
|
+
h.helpers.replay(cassette([interaction('{"task":"same","date":"yesterday"}', "ok", "/old")]));
|
|
112
|
+
expect(await (await h.send('{"task":"same","date":"today"}', "/new?nonce=123", { method: "PUT" })).text()).toBe("ok");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("JSON objects compare structurally but arrays preserve order", async () => {
|
|
116
|
+
const h = await harness({ key: request => request.json() });
|
|
117
|
+
h.helpers.replay(cassette([interaction('{"b":2,"a":{"x":[1,2]}}', "ok")]));
|
|
118
|
+
expect((await h.send('{"a":{"x":[2,1]},"b":2}')).status).toBe(599);
|
|
119
|
+
expect(await (await h.send('{ "a": {"x":[1,2]}, "b": 2 }')).text()).toBe("ok");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("binary keys are lossless and distinct from JSON lookalikes", async () => {
|
|
123
|
+
const h = await harness({ key: request => ({ body: request.bytes() }) });
|
|
124
|
+
const bytes = new Uint8Array([0, 255, 128]);
|
|
125
|
+
h.helpers.replay(cassette([interaction(bytes, "binary")]));
|
|
126
|
+
expect((await h.send("[0,255,128]")).status).toBe(599);
|
|
127
|
+
expect(await (await h.send(bytes)).text()).toBe("binary");
|
|
128
|
+
|
|
129
|
+
const mixed = await harness({ key: r => r.path === "/binary" ? new Uint8Array([1, 2]) : r.json() });
|
|
130
|
+
mixed.helpers.replay(cassette([interaction("", "bytes", "/binary"), interaction("[1,2]", "array"), interaction('{"0":1,"1":2}', "object")]));
|
|
131
|
+
expect(await (await mixed.send("[1,2]")).text()).toBe("array");
|
|
132
|
+
expect(await (await mixed.send('{"0":1,"1":2}')).text()).toBe("object");
|
|
133
|
+
expect(await (await mixed.send("", "/binary")).text()).toBe("bytes");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("empty and BOM-prefixed bodies preserve their bytes", async () => {
|
|
137
|
+
for (const key of [undefined, (r: ReplayRequest) => r.bytes()]) {
|
|
138
|
+
const h = await harness({ key });
|
|
139
|
+
h.helpers.replay(cassette([interaction("", "empty"), interaction("\ufeffhello", "bom")]));
|
|
140
|
+
expect(await (await h.send("")).text()).toBe("empty");
|
|
141
|
+
expect((await h.send("hello")).status).toBe(599);
|
|
142
|
+
expect(await (await h.send("\ufeffhello")).text()).toBe("bom");
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("same-key queues consume once, fail on exhaustion, and reset on reload", async () => {
|
|
147
|
+
const h = await harness();
|
|
148
|
+
const c = cassette([interaction("same", "first"), interaction("other", "other"), interaction("same", "second")]);
|
|
149
|
+
h.helpers.replay(c);
|
|
150
|
+
expect(await Promise.all([h.send("same"), h.send("same")].map(async p => (await p).text()))).toEqual(["first", "second"]);
|
|
151
|
+
const exhausted = await h.send("same");
|
|
152
|
+
expect(exhausted.status).toBe(599);
|
|
153
|
+
expect(await exhausted.text()).toContain("exhausted key");
|
|
154
|
+
expect(await (await h.send("missing")).text()).toContain("unknown key");
|
|
155
|
+
expect(await (await h.send("other")).text()).toBe("other");
|
|
156
|
+
h.helpers.replay(c);
|
|
157
|
+
expect(await (await h.send("same")).text()).toBe("first");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("cassette keys and cursors are isolated between fake states", async () => {
|
|
161
|
+
const a = await harness(), b = await harness();
|
|
162
|
+
const c = cassette([interaction("same", "ok")]);
|
|
163
|
+
a.helpers.replay(c); b.helpers.replay(c);
|
|
164
|
+
expect(await (await a.send("same")).text()).toBe("ok");
|
|
165
|
+
expect((await a.send("same")).status).toBe(599);
|
|
166
|
+
expect(await (await b.send("same")).text()).toBe("ok");
|
|
167
|
+
// Inline input cannot mutate a loaded/indexed cassette.
|
|
168
|
+
c.interactions[0].response.body = "mutated";
|
|
169
|
+
expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
|
|
170
|
+
a.helpers.dump().interactions[0].response.body = "mutated dump";
|
|
171
|
+
expect(a.helpers.dump().interactions[0].response.body).toBe("ok");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("request accessors are synchronous, reusable, and omit internal hashes/credentials", async () => {
|
|
175
|
+
const h = await harness({ key(r) {
|
|
176
|
+
expect("bodyHash" in r).toBe(false);
|
|
177
|
+
expect(r.headers.authorization).toBeUndefined();
|
|
178
|
+
expect(r.headers.cookie).toBeUndefined();
|
|
179
|
+
expect(Object.isFrozen(r)).toBe(true);
|
|
180
|
+
expect(Object.isFrozen(r.query)).toBe(true);
|
|
181
|
+
const bytes = r.bytes(); bytes.fill(0);
|
|
182
|
+
expect(r.text()).toBe('{"a":1}');
|
|
183
|
+
expect(r.json<{ a: number }>()).toEqual({ a: 1 });
|
|
184
|
+
return r.headers["x-task"]!;
|
|
185
|
+
} });
|
|
186
|
+
const it = interaction('{"a":1}', "ok");
|
|
187
|
+
it.request.headers["x-task"] = "journal";
|
|
188
|
+
h.helpers.replay(cassette([it]));
|
|
189
|
+
expect(await (await h.send('{"a":1}', "/messages", { headers: { "X-Task": "journal", authorization: "secret", cookie: "secret" } })).text()).toBe("ok");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("bad incoming JSON fails without consuming a response; bad loads preserve live state", async () => {
|
|
193
|
+
const h = await harness({ key: r => r.json() });
|
|
194
|
+
h.helpers.replay(cassette([interaction("{}", "ok")]));
|
|
195
|
+
expect(() => h.helpers.replay(cassette([interaction("invalid", "bad")]))).toThrow("interaction #1");
|
|
196
|
+
const bad = await h.send("invalid");
|
|
197
|
+
expect(bad.status).toBe(599);
|
|
198
|
+
expect(await bad.text()).toContain("invalid replay key");
|
|
199
|
+
expect(await (await h.send("{}")).text()).toBe("ok");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test.each([
|
|
203
|
+
undefined, NaN, Infinity, BigInt(1), new Date(), new Map(), Promise.resolve("x"),
|
|
204
|
+
{ missing: undefined }, [undefined], Array(1), () => "x",
|
|
205
|
+
])("rejects unsupported keys instead of silently collapsing identities: %p", async value => {
|
|
206
|
+
const h = await harness({ key: () => value as MatchKey });
|
|
207
|
+
expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("invalid replay key");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("rejects cyclic keys but permits shared subobjects", async () => {
|
|
211
|
+
const value: Record<string, MatchKey> = {};
|
|
212
|
+
value.self = value;
|
|
213
|
+
const bad = await harness({ key: () => value });
|
|
214
|
+
expect(() => bad.helpers.replay(cassette([interaction("", "bad")]))).toThrow("cycles");
|
|
215
|
+
const shared = { a: 1 };
|
|
216
|
+
const good = await harness({ key: () => [shared, shared] });
|
|
217
|
+
good.helpers.replay(cassette([interaction("", "ok")]));
|
|
218
|
+
expect(await (await good.send()).text()).toBe("ok");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("rejects an async callback that throws without leaking an unhandled rejection", async () => {
|
|
222
|
+
const h = await harness({ key: (async () => { throw new Error("async failure"); }) as unknown as ReplayFakeOptions["key"] });
|
|
223
|
+
expect(() => h.helpers.replay(cassette([interaction("", "bad")]))).toThrow("synchronous");
|
|
224
|
+
await Promise.resolve();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("old match options fail with migration guidance", () => {
|
|
228
|
+
expect(() => replayFake({ name: "test", host, match: { body: "hash" } } as unknown as ReplayFakeOptions)).toThrow("has been replaced by `key(request)`");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// Compile-time API checks, verified by the targeted TypeScript check as well
|
|
233
|
+
// as the SDK build. Never invoked: invalid callbacks must not run.
|
|
234
|
+
function publicTypes(request: ReplayRequest): void {
|
|
235
|
+
const json: ReplayKey = r => r.json();
|
|
236
|
+
const bytes: ReplayKey = r => ({ body: r.bytes(), query: r.query });
|
|
237
|
+
void [json, bytes];
|
|
238
|
+
// @ts-expect-error bodyHash is internal, not part of the callback view.
|
|
239
|
+
request.bodyHash;
|
|
240
|
+
// @ts-expect-error Request metadata is read-only.
|
|
241
|
+
request.method = "POST";
|
|
242
|
+
// @ts-expect-error Query values are read-only.
|
|
243
|
+
request.query.id.push("another");
|
|
244
|
+
// @ts-expect-error Callbacks must be synchronous.
|
|
245
|
+
const asyncKey: ReplayKey = async r => r.text();
|
|
246
|
+
// @ts-expect-error Undefined is not an identity.
|
|
247
|
+
const absentKey: ReplayKey = () => undefined;
|
|
248
|
+
void [asyncKey, absentKey];
|
|
249
|
+
replayFake({ name: "test", host,
|
|
250
|
+
// @ts-expect-error Per-dimension matching switches have been removed.
|
|
251
|
+
match: { body: "hash" },
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
void publicTypes;
|