@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.
@@ -0,0 +1,127 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { CassetteRequest } from "./replayFake.js";
3
+
4
+ export type JsonValue =
5
+ | null | boolean | number | string
6
+ | readonly JsonValue[]
7
+ | { readonly [key: string]: JsonValue };
8
+
9
+ /** Identity content. Objects compare independently of property order; arrays
10
+ * preserve order. Bytes are distinct from strings, arrays, and objects. */
11
+ export type MatchKey =
12
+ | null | boolean | number | string | Uint8Array
13
+ | readonly MatchKey[]
14
+ | { readonly [key: string]: MatchKey };
15
+
16
+ /** A buffered request view shared by incoming and recorded requests.
17
+ * Headers are lowercase and exclude credentials. Recorded values may be
18
+ * redacted; choose identity fields that survive recording. */
19
+ export interface ReplayRequest {
20
+ readonly method: string;
21
+ readonly path: string;
22
+ readonly query: Readonly<Record<string, readonly string[]>>;
23
+ readonly headers: Readonly<Record<string, string>>;
24
+ /** Decode UTF-8, throwing on invalid text. */
25
+ text(): string;
26
+ /** Parse JSON. The type parameter is not runtime validation. */
27
+ json<T = JsonValue>(): T;
28
+ /** Return an independent copy of the body bytes. */
29
+ bytes(): Uint8Array;
30
+ }
31
+
32
+ export type ReplayKey = (request: ReplayRequest) => MatchKey;
33
+
34
+ /** Stream a typed, length-delimited canonical representation into the hash.
35
+ * Never call toJSON: unsupported values must fail rather than silently lose
36
+ * identity fields. In particular, an async callback must not become `{}`. */
37
+ function hashKey(key: MatchKey): string {
38
+ const hash = createHash("sha256");
39
+ const ancestors = new Set<object>();
40
+ const visit = (value: unknown): void => {
41
+ if (value === null || typeof value === "boolean" || typeof value === "string") {
42
+ hash.update(JSON.stringify(value));
43
+ return;
44
+ }
45
+ if (typeof value === "number" && Number.isFinite(value)) {
46
+ hash.update(JSON.stringify(value));
47
+ return;
48
+ }
49
+ if (value instanceof Uint8Array) {
50
+ hash.update(`bytes:${value.byteLength}:`);
51
+ hash.update(value);
52
+ return;
53
+ }
54
+ if (value instanceof Promise) {
55
+ // Report the invalid callback synchronously without leaving a rejected
56
+ // async callback to become an unrelated unhandled-rejection failure.
57
+ void value.catch(() => {});
58
+ throw new Error("key callbacks must be synchronous, not return promises");
59
+ }
60
+ if (typeof value !== "object" || value === null) {
61
+ throw new Error("key must contain only JSON values or Uint8Array bytes (no undefined, functions, or non-finite numbers)");
62
+ }
63
+ if (ancestors.has(value)) throw new Error("key must not contain cycles");
64
+ if (Object.getOwnPropertySymbols(value).length > 0) {
65
+ throw new Error("key must not contain symbol properties");
66
+ }
67
+ ancestors.add(value);
68
+ if (Array.isArray(value)) {
69
+ if (Object.keys(value).length !== value.length) {
70
+ throw new Error("key arrays must be dense and have no extra properties");
71
+ }
72
+ hash.update("[");
73
+ for (let i = 0; i < value.length; i++) {
74
+ if (!Object.hasOwn(value, i)) throw new Error("key arrays must be dense");
75
+ visit(value[i]);
76
+ hash.update(",");
77
+ }
78
+ hash.update("]");
79
+ } else {
80
+ const proto = Object.getPrototypeOf(value);
81
+ if (proto !== Object.prototype && proto !== null) {
82
+ throw new Error("key must use plain objects, not promises, dates, maps, or class instances; callbacks must be synchronous");
83
+ }
84
+ hash.update("{");
85
+ for (const name of Object.keys(value).sort()) {
86
+ hash.update(JSON.stringify(name));
87
+ hash.update(":");
88
+ visit((value as Record<string, unknown>)[name]);
89
+ hash.update(",");
90
+ }
91
+ hash.update("}");
92
+ }
93
+ ancestors.delete(value);
94
+ };
95
+ visit(key);
96
+ return hash.digest("hex");
97
+ }
98
+
99
+ function bodyBytes(request: CassetteRequest): Uint8Array {
100
+ return Buffer.from(request.body ?? "", request.bodyEncoding === "base64" ? "base64" : "utf8");
101
+ }
102
+
103
+ function requestView(request: CassetteRequest): ReplayRequest {
104
+ let bytes: Uint8Array | undefined;
105
+ let text: string | undefined;
106
+ const readBytes = () => bytes ??= bodyBytes(request);
107
+ const readText = () => text ??= new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(readBytes());
108
+ const query = Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, Object.freeze([...v])]));
109
+ return Object.freeze({
110
+ method: request.method.toUpperCase(),
111
+ path: request.path,
112
+ query: Object.freeze(query),
113
+ headers: Object.freeze({ ...request.headers }),
114
+ text: readText,
115
+ json: <T = JsonValue>(): T => JSON.parse(readText()) as T,
116
+ bytes: () => Uint8Array.from(readBytes()),
117
+ });
118
+ }
119
+
120
+ /** Internal only; the SDK exports the request/key types, not this function. */
121
+ export function replaySignature(request: CassetteRequest, key?: ReplayKey): string {
122
+ if (key) return hashKey(key(requestView(request)));
123
+ // Existing cassettes retain the original byte hash even when their stored
124
+ // text was redacted. Older/hand-written cassettes may only have the body.
125
+ const body = request.bodyHash ?? `sha256:${createHash("sha256").update(bodyBytes(request)).digest("hex")}`;
126
+ return hashKey({ method: request.method.toUpperCase(), path: request.path, query: request.query, body });
127
+ }
@@ -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
+ }
@@ -0,0 +1,156 @@
1
+ import { 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
+
6
+ // Isolate module mocks from other tests. Drive the actual record handler,
7
+ // replacing only DNS/HTTPS transport; no credentials or external egress.
8
+ test("recorded headers, redaction, and exact body bytes survive record → replay", async () => {
9
+ const moduleUrl = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
10
+ const script = `
11
+ import { strict as assert } from "node:assert";
12
+ import { EventEmitter } from "node:events";
13
+ import { mock, test } from "bun:test";
14
+ import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
15
+
16
+ test("record round trip", async () => {
17
+ mock.module("node:dgram", () => ({ createSocket() {
18
+ const socket = new EventEmitter();
19
+ socket.close = () => {};
20
+ socket.send = (_query, _port, _host, done) => {
21
+ done();
22
+ queueMicrotask(() => socket.emit("message", dnsPacket.encode({
23
+ type: "response", id: 1,
24
+ answers: [{ type: "A", name: "api.example.test", ttl: 60, data: "127.0.0.1" }],
25
+ })));
26
+ };
27
+ return socket;
28
+ } }));
29
+
30
+ const upstreamHeaders = [];
31
+ mock.module("node:https", () => ({ request(options, callback) {
32
+ upstreamHeaders.push(options.headers);
33
+ const req = new EventEmitter();
34
+ req.write = () => {};
35
+ req.end = () => queueMicrotask(() => {
36
+ const res = new EventEmitter();
37
+ res.statusCode = 200;
38
+ res.headers = { "content-type": "text/plain" };
39
+ callback(res);
40
+ res.emit("data", Buffer.from("response"));
41
+ res.emit("end");
42
+ });
43
+ return req;
44
+ } }));
45
+
46
+ const { replayFake } = await import(${moduleUrl("./replayFake.ts")});
47
+ const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
48
+ setRecordSecrets({ API_KEY: "recording-secret" });
49
+ const options = {
50
+ name: "test", host: "api.example.test",
51
+ inject: [{ headers: { authorization: "Bearer {{secret:API_KEY}}" } }],
52
+ redactPatterns: ["private-field"],
53
+ };
54
+ const record = replayFake({ ...options, mode: "record" });
55
+ const state = record.state();
56
+ const helpers = record.helpers({ name: "test", state, ctx: {} });
57
+ const bodies = ["\\ufeffhello", new Uint8Array([0, 128, 255]), "recording-secret private-field"];
58
+ for (const [i, body] of bodies.entries()) {
59
+ const res = await record.handler(new Request("https://api.example.test/messages", {
60
+ method: "POST", body,
61
+ headers: {
62
+ "X-Task": "task-" + i,
63
+ authorization: "Bearer dummy", cookie: "dummy-cookie",
64
+ "api-key": "dummy-api-key", "x-goog-api-key": "dummy-google-key",
65
+ "x-extra": "recording-secret private-field",
66
+ },
67
+ }), state, {});
68
+ assert.equal(res.status, 200, await res.text());
69
+ }
70
+ assert.equal(helpers.count(), 3);
71
+ assert.equal(upstreamHeaders[0].authorization, "Bearer recording-secret");
72
+ const cassette = JSON.parse(JSON.stringify(helpers.dump()));
73
+ assert.equal(cassette.interactions[0].request.body, "\\ufeffhello");
74
+ assert.equal(cassette.interactions[1].request.bodyEncoding, "base64");
75
+ assert.equal(cassette.interactions[2].request.body, "<REDACTED:API_KEY> <REDACTED>");
76
+ for (const [i, it] of cassette.interactions.entries()) {
77
+ assert.equal(it.request.headers["x-task"], "task-" + i);
78
+ assert.equal(it.request.headers["x-extra"], "<REDACTED:API_KEY> <REDACTED>");
79
+ for (const name of ["authorization", "cookie", "api-key", "x-goog-api-key"]) {
80
+ assert.equal(it.request.headers[name], undefined);
81
+ }
82
+ }
83
+ assert.equal(JSON.stringify(cassette).includes("recording-secret"), false);
84
+
85
+ for (const key of [undefined, r => r.headers["x-task"]]) {
86
+ const replay = replayFake({ ...options, mode: "replay", key });
87
+ const replayState = replay.state();
88
+ replay.helpers({ name: "test", state: replayState, ctx: {} }).replay(cassette);
89
+ for (const i of [2, 0, 1]) {
90
+ const res = await replay.handler(new Request("https://api.example.test/messages", {
91
+ method: "POST", body: bodies[i], headers: { "X-Task": "task-" + i },
92
+ }), replayState, {});
93
+ assert.equal(res.status, 200);
94
+ assert.equal(await res.text(), "response");
95
+ }
96
+ }
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
+ }
140
+ });
141
+ `;
142
+ const dir = await mkdtemp(join(tmpdir(), "spectest-replay-record-"));
143
+ try {
144
+ const file = join(dir, "record.test.ts");
145
+ await writeFile(file, script);
146
+ const child = Bun.spawn([process.execPath, "test", file], {
147
+ stdout: "pipe", stderr: "pipe",
148
+ });
149
+ const [exit, stdout, stderr] = await Promise.all([
150
+ child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
151
+ ]);
152
+ expect(exit, stdout + stderr).toBe(0);
153
+ } finally {
154
+ await rm(dir, { recursive: true, force: true });
155
+ }
156
+ }, 10_000);