@specific.dev/spectest 0.109.0 → 0.110.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.
Files changed (39) hide show
  1. package/dist/components/index.d.ts +1 -1
  2. package/dist/components/replay-key.d.ts +4 -0
  3. package/dist/components/replay-key.js +1 -1
  4. package/dist/components/replay-websocket.d.ts +75 -0
  5. package/dist/components/replay-websocket.js +414 -0
  6. package/dist/components/replayFake.d.ts +21 -4
  7. package/dist/components/replayFake.js +115 -13
  8. package/dist/daemon.js +17 -2
  9. package/dist/fake-websocket.d.ts +2 -0
  10. package/dist/harness/background-failures.d.ts +2 -0
  11. package/dist/harness/background-failures.js +19 -0
  12. package/dist/harness/fake-websocket-connection.d.ts +2 -2
  13. package/dist/harness/fake-websocket-connection.js +61 -27
  14. package/dist/harness/fake-websocket-lifetime.d.ts +22 -0
  15. package/dist/harness/fake-websocket-lifetime.js +50 -0
  16. package/dist/harness/fake-websocket.js +28 -9
  17. package/dist/helper-tracking.js +4 -2
  18. package/dist/replay-mode.d.ts +2 -0
  19. package/dist/replay-mode.js +12 -0
  20. package/package.json +1 -1
  21. package/src/components/index.ts +5 -0
  22. package/src/components/replay-key.ts +1 -1
  23. package/src/components/replay-websocket.ts +387 -0
  24. package/src/components/replayFake-handler.test.ts +2 -1
  25. package/src/components/replayFake-record.test.ts +7 -1
  26. package/src/components/replayFake-websocket-record.test.ts +110 -0
  27. package/src/components/replayFake-websocket.test.ts +264 -0
  28. package/src/components/replayFake.ts +125 -15
  29. package/src/daemon.ts +18 -2
  30. package/src/fake-websocket.ts +2 -0
  31. package/src/harness/background-failures.ts +19 -0
  32. package/src/harness/fake-websocket-connection.ts +45 -24
  33. package/src/harness/fake-websocket-lifetime.test.ts +185 -0
  34. package/src/harness/fake-websocket-lifetime.ts +52 -0
  35. package/src/harness/fake-websocket.ts +22 -10
  36. package/src/helper-tracking.test.ts +36 -0
  37. package/src/helper-tracking.ts +4 -2
  38. package/src/replay-mode.test.ts +96 -0
  39. package/src/replay-mode.ts +14 -0
@@ -0,0 +1,264 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { once } from "node:events";
3
+ import { createRequire } from "node:module";
4
+ import { dirname, join } from "node:path";
5
+ import type { FakeContext, FakeWebSocket } from "../index.js";
6
+ import { replayFake, type Cassette, type CassetteWebSocket, type WebSocketKey } from "./index.js";
7
+ import { recordWebSocket, websocketMatcher } from "./replay-websocket.js";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const { WebSocketServer } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
11
+ const ctx = {} as FakeContext;
12
+ const request = { method: "GET", path: "/live", query: {}, headers: {} };
13
+ const cleanup: (() => void)[] = [];
14
+ afterEach(() => { for (const fn of cleanup.splice(0).reverse()) fn(); });
15
+ const tick = (ms = 15) => Bun.sleep(ms);
16
+ const signature = (message: string | Uint8Array) => websocketMatcher()(message)!;
17
+ function cassette(websockets: CassetteWebSocket[]): Cassette {
18
+ return { version: 1, fake: "voice", host: "voice.test", interactions: [], websockets };
19
+ }
20
+ async function harness<W>(recordings: CassetteWebSocket[], websocketKey?: WebSocketKey<W>, timeout = 1000) {
21
+ const fake = replayFake({ name: "voice", host: "voice.test", mode: "replay", websocketKey, websocketTimeoutMs: timeout });
22
+ const state = await fake.state!();
23
+ const helpers = await fake.helpers!({ name: "voice", state, ctx });
24
+ helpers.replay(cassette(recordings));
25
+ const dispose = () => { for (const session of state.websocketSessions) session.cancel(); };
26
+ cleanup.push(dispose);
27
+ async function connect(protocol?: string) {
28
+ const result = await fake.websocket!(new Request("https://voice.test/live", {
29
+ headers: { upgrade: "websocket", "sec-websocket-key": Math.random().toString(), ...(protocol ? { "sec-websocket-protocol": protocol } : {}) },
30
+ }), state, ctx);
31
+ if (result instanceof Response) throw new Error(await result.text());
32
+ const sent: (string | Uint8Array)[] = [];
33
+ const closes: { code?: number; reason?: string }[] = [];
34
+ const socket: FakeWebSocket = {
35
+ readyState: 1,
36
+ send(message) { sent.push(typeof message === "string" ? message : new Uint8Array(message)); },
37
+ close(code, reason) { closes.push({ code, reason }); },
38
+ };
39
+ await result.open?.(socket);
40
+ return { sent, closes, protocol: result.protocol,
41
+ message: (message: string | Uint8Array) => result.message?.(socket, message),
42
+ close: (code = 1000, reason = "") => result.close?.(socket, code, reason),
43
+ };
44
+ }
45
+ return { fake, state, helpers, connect, dispose };
46
+ }
47
+
48
+ test("default hashes type and complete payload; custom keys use canonical hashing", () => {
49
+ const match = websocketMatcher();
50
+ expect(match("audio")).not.toBe(match(new TextEncoder().encode("audio")));
51
+ expect(match("audio")).not.toBe(match("audio!"));
52
+ expect(websocketMatcher(() => ({ b: 2, a: 1 }))("x")).toBe(websocketMatcher(() => ({ a: 1, b: 2 }))("y"));
53
+ expect(websocketMatcher(() => [1, 2])("x")).not.toBe(websocketMatcher(() => [2, 1])("x"));
54
+ expect(websocketMatcher(() => new Uint8Array([1]))("x")).not.toBe(websocketMatcher(() => "\u0001")("x"));
55
+ });
56
+
57
+ test("barriers delay output; reception continues during playback; repeated keys consume in order", async () => {
58
+ const h = await harness([{ request, inputs: [signature("go"), signature("go"), signature("tool-result")], messages: [
59
+ { afterInput: 0, delayMs: 0, type: "text", data: "greeting" },
60
+ { afterInput: 1, delayMs: 60, type: "text", data: "first" },
61
+ { afterInput: 2, delayMs: 0, type: "binary", data: "AP8=" },
62
+ { afterInput: 3, delayMs: 0, type: "text", data: "tool-dependent" },
63
+ ], close: { afterInput: 3, delayMs: 2, initiator: "server", code: 4001, reason: "done" } }]);
64
+ const c = await h.connect();
65
+ await tick(); expect(c.sent).toEqual(["greeting"]);
66
+ await c.message("go"); await c.message("go");
67
+ await tick(); expect(c.sent).toEqual(["greeting"]);
68
+ await tick(70); expect(c.sent).toEqual(["greeting", "first", new Uint8Array([0, 255])]);
69
+ expect(h.state.websocketSessions.size).toBe(1);
70
+ await c.message("tool-result"); await tick();
71
+ expect(c.sent.at(-1)).toBe("tool-dependent");
72
+ expect(c.closes).toEqual([{ code: 4001, reason: "done" }]);
73
+ await c.close(4001, "done");
74
+ expect(h.state.websocketErrors).toEqual([]);
75
+ expect(h.state.websocketSessions.size).toBe(0);
76
+ });
77
+
78
+ test("connections consume handshake recordings in order and clone reducer state", async () => {
79
+ const key: WebSocketKey<{ seen: number }> = { initial: { seen: 0 }, reduce(state) {
80
+ state.seen++;
81
+ return { state, key: state.seen === 3 ? "turn" : null };
82
+ } };
83
+ const hash = websocketMatcher(() => "turn")("ignored")!;
84
+ const h = await harness(["one", "two"].map(data => ({ request, protocol: "voice", inputs: [hash], messages: [
85
+ { afterInput: 1, delayMs: 0, type: "text", data },
86
+ ] })), key);
87
+ const a = await h.connect("voice"), b = await h.connect("voice");
88
+ expect(a.protocol).toBe("voice");
89
+ await a.message("audio"); await a.message("audio"); await b.message("audio"); await a.message("audio");
90
+ await tick(); expect(a.sent).toEqual(["one"]); expect(b.sent).toEqual([]);
91
+ await b.message("audio"); await b.message("audio"); await tick(); expect(b.sent).toEqual(["two"]);
92
+ expect(key.initial.seen).toBe(0);
93
+ await a.close(); await b.close(); expect(h.state.websocketErrors).toEqual([]);
94
+ await expect(h.connect("voice")).rejects.toThrow("exhausted");
95
+ });
96
+
97
+ test("mixed HTTP and WebSocket cassette uses separate indexes and unchanged HTTP key", async () => {
98
+ const h = await harness([{ request, inputs: [], messages: [{ afterInput: 0, delayMs: 0, type: "text", data: "ws" }] }]);
99
+ const recording = h.helpers.dump();
100
+ recording.interactions.push({ request, response: { status: 200, headers: {}, body: "http", bodyEncoding: "utf8" } });
101
+ h.helpers.replay(recording);
102
+ const c = await h.connect();
103
+ expect(await (await h.fake.handler(new Request("https://voice.test/live"), h.state, ctx)).text()).toBe("http");
104
+ await tick(); expect(c.sent).toEqual(["ws"]); await c.close();
105
+ expect(h.state.websocketErrors).toEqual([]);
106
+ h.helpers.replay({ version: 1, fake: "voice", host: "voice.test", interactions: recording.interactions });
107
+ expect(await (await h.fake.handler(new Request("https://voice.test/live"), h.state, ctx)).text()).toBe("http");
108
+ });
109
+
110
+ test("mismatch signatures, missing input deadline, unexpected closure and cleanup are visible", async () => {
111
+ const r: CassetteWebSocket = { request, inputs: [signature("expected")], messages: [{ afterInput: 1, delayMs: 0, type: "text", data: "dependent" }] };
112
+ const mismatch = await harness([r]); const a = await mismatch.connect();
113
+ expect(() => a.message("actual")).toThrow(`expected ${signature("expected")}, actual ${signature("actual")}`);
114
+ expect(mismatch.state.websocketErrors.join("\n")).toContain("connection #1");
115
+ const missing = await harness([r], undefined, 20); const b = await missing.connect();
116
+ await tick(40); expect(b.sent).toEqual([]); expect(b.closes[0].code).toBe(1011);
117
+ expect(missing.state.websocketErrors.join("\n")).toContain("required input never arrived");
118
+ const early = await harness([r]); const c = await early.connect(); await c.close();
119
+ expect(early.state.websocketErrors.join("\n")).toContain("required input never arrived");
120
+ const cancel = await harness([{ request, inputs: [], messages: [{ afterInput: 0, delayMs: 30, type: "text", data: "late" }] }]);
121
+ const d = await cancel.connect(); cancel.dispose(); await tick(50);
122
+ expect(d.sent).toEqual([]); expect(cancel.state.websocketSessions.size).toBe(0);
123
+ });
124
+
125
+ test("invalid and async matchers fail synchronously", () => {
126
+ for (const key of [async () => "x", () => undefined, { initial: 0, reduce: async () => ({ state: 1, key: "x" }) }, { initial: 0, reduce: () => ({ key: null }) }]) {
127
+ expect(() => websocketMatcher(key as any)("input")).toThrow();
128
+ }
129
+ expect(() => replayFake({ name: "bad", host: "voice.test", websocketKey: {} as any })).toThrow("websocketKey");
130
+ });
131
+
132
+ test("cassette validation is atomic and rejects impossible dependencies", async () => {
133
+ const h = await harness([{ request, inputs: [], messages: [] }]);
134
+ expect(() => h.helpers.replay(cassette([{ request, inputs: [], messages: [{ type: "text", data: "bad", afterInput: 1, delayMs: 0 }] }]))).toThrow("dependency");
135
+ const c = await h.connect(); await c.close(); expect(h.state.websocketErrors).toEqual([]);
136
+ });
137
+
138
+ test("real recording transport collapses 500 audio messages, preserves output types and closure, then replays", async () => {
139
+ const server = new WebSocketServer({ port: 0, host: "127.0.0.1", handleProtocols: () => "voice" });
140
+ await once(server, "listening");
141
+ cleanup.push(() => { for (const c of server.clients) c.terminate(); server.close(); });
142
+ let incoming = 0;
143
+ server.on("connection", ws => {
144
+ ws.send("greeting secret");
145
+ ws.on("message", (data, binary) => {
146
+ expect(new Uint8Array(data as Buffer)).toEqual(new Uint8Array([12, 34, 56, 78]));
147
+ incoming++;
148
+ expect(binary).toBe(true);
149
+ if (incoming === 500) { ws.send(new Uint8Array([0, 255, 128])); ws.close(4001, "done"); }
150
+ });
151
+ });
152
+ const key: WebSocketKey<{ count: number }> = { initial: { count: 0 }, reduce(state, message) {
153
+ if (message instanceof Uint8Array) message[0] = 255; // Must not change forwarding.
154
+ return { state: { count: state.count + 1 }, key: state.count === 499 ? "caller-turn" : null };
155
+ } };
156
+ let recorded: CassetteWebSocket | undefined;
157
+ let done!: () => void;
158
+ const closed = new Promise<void>(resolve => done = resolve);
159
+ const errors: Error[] = [];
160
+ const r = await recordWebSocket({
161
+ url: `ws://voice.test:${(server.address() as { port: number }).port}/live`, ip: "127.0.0.1", host: "voice.test", headers: {}, protocols: ["voice"], request,
162
+ match: websocketMatcher(key), redact: s => s.replaceAll("secret", "<REDACTED>"), secrets: [{ ref: "TOKEN", value: "secret" }], label: "voice connection #1",
163
+ captured: record => recorded = record,
164
+ finished(_session, error) { if (error) errors.push(error); done(); },
165
+ });
166
+ cleanup.push(() => r.session.cancel());
167
+ const outputs: (string | Uint8Array | ArrayBuffer)[] = [];
168
+ const socket: FakeWebSocket = { readyState: 1, send: value => { outputs.push(value); }, close() {} };
169
+ await r.handlers.open!(socket);
170
+ for (let i = 0; i < 500; i++) await r.handlers.message!(socket, new Uint8Array([12, 34, 56, 78]));
171
+ await closed;
172
+ expect(errors).toEqual([]); expect(incoming).toBe(500);
173
+ expect(outputs).toEqual(["greeting secret", new Uint8Array([0, 255, 128])]);
174
+ expect(recorded!.inputs).toHaveLength(1); expect(recorded!.protocol).toBe("voice");
175
+ expect(recorded!.messages[0]).toMatchObject({ afterInput: 0, data: "greeting <REDACTED>" });
176
+ expect(recorded!.messages[1]).toMatchObject({ afterInput: 1, type: "binary" });
177
+ expect(recorded!.close).toMatchObject({ initiator: "server", code: 4001 });
178
+ expect(JSON.stringify(recorded)).not.toContain("12,34");
179
+ expect(JSON.stringify(recorded)).not.toContain("secret");
180
+ expect(JSON.stringify(recorded).length).toBeLessThan(1200);
181
+ const h = await harness([recorded!], key); const c = await h.connect("voice");
182
+ for (let i = 0; i < 500; i++) await c.message(new Uint8Array([12, 34, 56, 78]));
183
+ await tick(60);
184
+ expect(c.sent).toEqual(["greeting <REDACTED>", new Uint8Array([0, 255, 128])]);
185
+ await c.close(4001, "done"); expect(h.state.websocketErrors).toEqual([]);
186
+ });
187
+
188
+ // Compiled by the existing public API typecheck test.
189
+ export function inferredReducer() {
190
+ return replayFake({ name: "inferred", host: "voice.test", state: () => ({ counter: 0 }),
191
+ websocketKey: { initial: { heardAudio: false, quietMessages: 0 }, reduce(state, message) {
192
+ const count: number = state.quietMessages;
193
+ const heard: boolean = state.heardAudio;
194
+ const payload: string | Uint8Array = message;
195
+ // @ts-expect-error reducer state must be inferred from initial
196
+ const wrong: string = state.quietMessages;
197
+ return { state: { heardAudio: heard, quietMessages: count + 1 }, key: payload };
198
+ } },
199
+ });
200
+ }
201
+
202
+ test("quiet-message example ignores leading quiet, resets on speech, and keys each control event", async () => {
203
+ const { quietMessageKey } = await import("../../../examples/replay-fake/spectest/quiet-message-key.js");
204
+ const match = websocketMatcher(quietMessageKey(3));
205
+ const audio = (bytes: number[]) => JSON.stringify({ type: "session.input_audio.append", audio: Buffer.from(bytes).toString("base64") });
206
+ for (let i = 0; i < 10; i++) expect(match(audio([0, 0]))).toBeNull();
207
+ for (let i = 0; i < 500; i++) expect(match(audio([1, 0]))).toBeNull();
208
+ expect(match(audio([0, 0]))).toBeNull(); expect(match(audio([0, 0]))).toBeNull();
209
+ expect(match(audio([1, 0]))).toBeNull();
210
+ expect(match(audio([0, 0]))).toBeNull(); expect(match(audio([0, 0]))).toBeNull();
211
+ expect(match(audio([0, 0]))).toBe(websocketMatcher(() => ({ type: "caller-turn" }))(""));
212
+ expect(match(audio([0, 0]))).toBeNull();
213
+ expect(match(JSON.stringify({ type: "tool.result", content: "done", event_id: "old" }))).toBe(match(JSON.stringify({ event_id: "new", content: "done", type: "tool.result" })));
214
+ expect(match(JSON.stringify({ type: "continuation" }))).not.toBeNull();
215
+ expect(() => match(audio([]))).toThrow("complete PCM16");
216
+ });
217
+
218
+ test("server close acknowledgments may omit the reason while client close reasons must match", async () => {
219
+ const h = await harness([{ request, inputs: [], messages: [], close: { initiator: "server", code: 1000, reason: "complete", afterInput: 0, delayMs: 0 } }]);
220
+ const c = await h.connect();
221
+ await tick();
222
+ expect(c.closes).toEqual([{ code: 1000, reason: "complete" }]);
223
+ await c.close(1000, "");
224
+ expect(h.state.websocketErrors).toEqual([]);
225
+ expect(h.state.websocketSessions.size).toBe(0);
226
+ const client = await harness([{ request, inputs: [], messages: [], close: { initiator: "client", code: 1000, reason: "complete", afterInput: 0, delayMs: 0 } }]);
227
+ const peer = await client.connect();
228
+ await peer.close(1000, "");
229
+ expect(client.state.websocketErrors.join("\n")).toContain('expected reason "complete", actual ""');
230
+ });
231
+
232
+ test("mismatched close and input exhaustion remain errors; cleanup cancels missing-input deadline", async () => {
233
+ const h = await harness([{ request, inputs: [], messages: [], close: { initiator: "client", code: 4000, reason: "expected", afterInput: 0, delayMs: 0 } }]);
234
+ const c = await h.connect(); await c.close(4001, "different");
235
+ expect(h.state.websocketErrors.join("\n")).toContain("unexpected connection closure");
236
+ const exhausted = await harness([{ request, inputs: [], messages: [] }]); const a = await exhausted.connect();
237
+ expect(() => a.message("extra")).toThrow("<exhausted>");
238
+ const pending = await harness([{ request, inputs: [signature("go")], messages: [] }], undefined, 20);
239
+ await pending.connect(); pending.dispose(); await tick(40);
240
+ expect(pending.state.websocketErrors).toEqual([]);
241
+ });
242
+
243
+
244
+ test("malformed outputs and closure dependencies are rejected before consumption", async () => {
245
+ const h = await harness([{ request, inputs: [], messages: [] }]);
246
+ for (const message of [null, { afterInput: 0, delayMs: 0 }, { type: "binary", data: "!", afterInput: 0, delayMs: 0 }]) {
247
+ expect(() => h.helpers.replay(cassette([{ request, inputs: [], messages: [message as any] }]))).toThrow();
248
+ }
249
+ expect(() => h.helpers.replay(cassette([{ request, inputs: [signature("late")], messages: [], close: {
250
+ afterInput: 0, delayMs: 0, initiator: "server", code: 1000, reason: "",
251
+ } }]))).toThrow("closure");
252
+ const c = await h.connect(); await c.close(); expect(h.state.websocketErrors).toEqual([]);
253
+ });
254
+
255
+ test("an abandoned handshake cancels the connection before open", async () => {
256
+ const h = await harness([{ request, inputs: [signature("go")], messages: [] }]);
257
+ const controller = new AbortController();
258
+ const handlers = await h.fake.websocket!(new Request("https://voice.test/live", { signal: controller.signal }), h.state, ctx);
259
+ expect(handlers).not.toBeInstanceOf(Response);
260
+ expect(h.state.websocketSessions.size).toBe(1);
261
+ controller.abort();
262
+ expect(h.state.websocketSessions.size).toBe(0);
263
+ expect(h.state.websocketErrors).toEqual([]);
264
+ });
@@ -31,10 +31,9 @@
31
31
  // with our in-VM-CA leaf; the upstream leg is a genuine public-cert TLS
32
32
  // handshake to the real host.
33
33
  //
34
- // Mode is chosen by `isRecording()`: it is true only inside an active
35
- // recorder (a `spectest test` case), false in eval/manual. So `auto`
36
- // (the default) replays under test and records during manual use — no new
37
- // control-plane mode flag. `mode: "record" | "replay"` overrides it.
34
+ // Mode belongs to the environment, not the per-case event recorder. Tests
35
+ // and shared baselines always replay, including startup and between cases.
36
+ // Interactive environments record unless `mode: "replay"` is requested.
38
37
  // An optional custom handler can return scripted responses or explicitly
39
38
  // delegate with ctx.replay(request). Only delegated traffic uses this engine.
40
39
  //
@@ -65,8 +64,15 @@ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
65
64
  import * as dnsPacket from "dns-packet";
66
65
 
67
66
  import type { FakeContext, FakeDefinition } from "../index.js";
68
- import { isRecording } from "../recorder.js";
67
+ import { resolveReplayMode } from "../replay-mode.js";
68
+ import { getWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
69
+ import { reportBackgroundFailure } from "../harness/background-failures.js";
69
70
  import { getRecordSecret } from "../record-secrets.js";
71
+ import {
72
+ recordWebSocket, replayWebSocket, validateWebSocketKey, validateWebSocketRecording,
73
+ websocketMatcher, type WebSocketKey, type CassetteWebSocket, type WebSocketSession,
74
+ } from "./replay-websocket.js";
75
+ export type { WebSocketKey, ReplayWebSocketMessage, CassetteWebSocket, CassetteWebSocketMessage, WebSocketTiming } from "./replay-websocket.js";
70
76
  import { replaySignature, type ReplayKey } from "./replay-key.js";
71
77
  export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
72
78
  import {
@@ -152,6 +158,7 @@ export type ReplayHandler<S> = (
152
158
  export interface ReplayFakeOptions<
153
159
  S = undefined,
154
160
  H extends Record<string, unknown> = {},
161
+ W = unknown,
155
162
  > {
156
163
  /** Stable name — the `ctx.fakes` key. */
157
164
  name: string;
@@ -169,6 +176,13 @@ export interface ReplayFakeOptions<
169
176
  * bytes; objects compare independently of property order. No implicit
170
177
  * matching dimensions or fallback apply when a key is supplied. */
171
178
  key?: ReplayKey;
179
+ /** Complete messages (string or Uint8Array), synchronously reduced per connection.
180
+ * Return null to emit no input. Raw inputs are never stored; changing this
181
+ * matcher can require re-recording. Defaults to SHA-256(type + bytes). */
182
+ websocketKey?: WebSocketKey<W>;
183
+ /** Maximum wait for each required input key; null-producing messages do not
184
+ * extend it. Defaults to 30 seconds. */
185
+ websocketTimeoutMs?: number;
172
186
  /** Per-fake credential brokering. Rules are evaluated in
173
187
  * order; the first whose `match` matches wins (a rule without `match`
174
188
  * matches everything and shadows later rules). Applied at the
@@ -184,8 +198,9 @@ export interface ReplayFakeOptions<
184
198
  /** Extra substrings/patterns to scrub from stored request/response
185
199
  * bodies and queries (every injected secret value is always scrubbed). */
186
200
  redactPatterns?: (string | RegExp)[];
187
- /** `"auto"` (default) records under eval and replays under test;
188
- * `"record"` / `"replay"` force a mode. */
201
+ /** `"auto"` (default) records in interactive environments and replays in
202
+ * tests. `"replay"` always replays; `"record"` requires an interactive
203
+ * environment. Test environments always replay, even between cases. */
189
204
  mode?: "auto" | "record" | "replay";
190
205
  /** Custom state, with the same lifecycle as a normal fake's state. Kept
191
206
  * separate from cassette internals; helpers and handler see only S. */
@@ -233,6 +248,7 @@ export interface Cassette {
233
248
  /** The real host this cassette mirrors (e.g. `api.stripe.com`). */
234
249
  host: string;
235
250
  interactions: CassetteInteraction[];
251
+ websockets?: CassetteWebSocket[];
236
252
  }
237
253
 
238
254
  /** Forked-per-test state for one replay fake. */
@@ -245,6 +261,11 @@ export interface CassetteState {
245
261
  /** Interactions captured this record session. */
246
262
  recorded: CassetteInteraction[];
247
263
  dirty: boolean;
264
+ websocketIndex: Map<string, number[]>;
265
+ websocketCursor: Map<string, number>;
266
+ recordedWebSockets: CassetteWebSocket[];
267
+ websocketSessions: Set<WebSocketSession>;
268
+ websocketErrors: string[];
248
269
  }
249
270
 
250
271
  /** What `replay(...)` returns — a small summary that surfaces in the test
@@ -257,6 +278,7 @@ export interface ReplaySummary {
257
278
  host: string;
258
279
  /** Number of interactions now available for replay. */
259
280
  interactions: number;
281
+ websockets?: number;
260
282
  }
261
283
 
262
284
  export interface ReplayHelpers extends Record<string, unknown> {
@@ -382,6 +404,7 @@ function coerceCassette(parsed: Cassette, name: string, host: string): Cassette
382
404
  fake: parsed.fake ?? name,
383
405
  host: parsed.host ?? host,
384
406
  interactions: structuredClone(parsed.interactions),
407
+ ...(parsed.websockets !== undefined ? { websockets: structuredClone(parsed.websockets) } : {}),
385
408
  };
386
409
  }
387
410
 
@@ -658,8 +681,8 @@ function forwardToRealUpstream(args: {
658
681
  // replayFake
659
682
  // ────────────────────────────────────────────────────────────────────────
660
683
 
661
- export function replayFake<S = undefined, H extends Record<string, unknown> = {}>(
662
- opts: ReplayFakeOptions<S, H>,
684
+ export function replayFake<S = undefined, H extends Record<string, unknown> = {}, W = unknown>(
685
+ opts: ReplayFakeOptions<S, H, W>,
663
686
  ): FakeDefinition<CassetteState & { custom: S }, ReplayHelpers & H> {
664
687
  if (!opts.name) throw new Error("replayFake: `name` is required");
665
688
  if (!opts.host || typeof opts.host !== "string") {
@@ -672,17 +695,18 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
672
695
  if (opts.key !== undefined && typeof opts.key !== "function") {
673
696
  throw new Error(`replayFake(${opts.name}): \`key\` must be a synchronous function`);
674
697
  }
698
+ validateWebSocketKey(opts.websocketKey);
699
+ const websocketTimeoutMs = opts.websocketTimeoutMs ?? 30_000;
700
+ if (!Number.isFinite(websocketTimeoutMs) || websocketTimeoutMs <= 0 || websocketTimeoutMs > 2_147_483_647) {
701
+ throw new Error(`replayFake(${opts.name}): websocketTimeoutMs must be positive and at most 2147483647`);
702
+ }
675
703
  const injectRules = opts.inject ?? [];
676
704
  // Refs every header template references — the control plane resolves these
677
705
  // server-side and pushes them for the environment lifetime (see the daemon's
678
706
  // /record-secret-refs endpoint, which reads `def.secretRefs`).
679
707
  const secretRefs = collectSecretRefs(injectRules, opts.sign);
680
708
 
681
- const resolveMode = (): "record" | "replay" => {
682
- if (opts.mode === "record") return "record";
683
- if (opts.mode === "replay") return "replay";
684
- return isRecording() ? "replay" : "record";
685
- };
709
+ const resolveMode = () => resolveReplayMode(opts.mode);
686
710
 
687
711
  // ── REPLAY ──────────────────────────────────────────────────────────
688
712
  function signature(request: CassetteRequest, location: string): string {
@@ -974,6 +998,74 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
974
998
  return record(req, url, reqLike, bodyBytes, state);
975
999
  };
976
1000
 
1001
+ const websocket: NonNullable<FakeDefinition<CassetteState>["websocket"]> = async (req, state) => {
1002
+ const url = new URL(req.url);
1003
+ const request: CassetteRequest = {
1004
+ method: req.method.toUpperCase(), path: url.pathname,
1005
+ query: normalizeQuery(url.searchParams, opts.sign !== undefined),
1006
+ headers: recordable(req.headers, [...req.headers.keys()].filter(k => !["sec-websocket-key", "sec-websocket-accept"].includes(k))),
1007
+ };
1008
+ const sig = signature(request, "WebSocket handshake");
1009
+ const match = websocketMatcher(opts.websocketKey);
1010
+ const lifetime = getWebSocketLifetime(req);
1011
+ const track = (session: WebSocketSession) => {
1012
+ if (!lifetime.ended) state.websocketSessions.add(session);
1013
+ };
1014
+ const finished = (session: WebSocketSession, error?: Error) => {
1015
+ state.websocketSessions.delete(session);
1016
+ if (error) { state.websocketErrors.push(error.message); reportBackgroundFailure(error); }
1017
+ };
1018
+ const offered = (req.headers.get("sec-websocket-protocol") ?? "").split(",").map(p => p.trim()).filter(Boolean);
1019
+ if (resolveMode() === "replay") {
1020
+ const position = state.websocketCursor.get(sig) ?? 0;
1021
+ const index = state.websocketIndex.get(sig)?.[position];
1022
+ const label = `replayFake(${opts.name}) WebSocket ${sig} connection #${position + 1}`;
1023
+ if (index === undefined) {
1024
+ const error = `${label}: no recording available (handshake unmatched or exhausted)`;
1025
+ state.websocketErrors.push(error);
1026
+ reportBackgroundFailure(new Error(error));
1027
+ return new Response(error, { status: 599 });
1028
+ }
1029
+ const recording = state.cassette.websockets![index];
1030
+ if (recording.protocol && !offered.includes(recording.protocol)) {
1031
+ throw new Error(`${label}: client did not offer recorded subprotocol`);
1032
+ }
1033
+ const { handlers, session } = replayWebSocket({ recording, match, label, timeoutMs: websocketTimeoutMs, lifetime, finished });
1034
+ state.websocketCursor.set(sig, position + 1);
1035
+ track(session);
1036
+ return handlers;
1037
+ }
1038
+ if (opts.sign) throw new Error(`replayFake(${opts.name}): SigV4 WebSocket signing is not supported`);
1039
+ const rule = injectRules.find(r => ruleMatches(r.match, {
1040
+ method: request.method, path: request.path, query: url.searchParams, headers: req.headers,
1041
+ }));
1042
+ const brokered = rule ? brokerHeaders(rule) : { headers: {}, secrets: [], missing: [] };
1043
+ if (brokered.missing.length) throw new Error(`replayFake(${opts.name}): missing WebSocket secrets ${brokered.missing.join(", ")}`);
1044
+ const redact = makeRedactor(brokered.secrets, opts.redactPatterns);
1045
+ const storedRequest: CassetteRequest = {
1046
+ ...request,
1047
+ query: Object.fromEntries(Object.entries(request.query).map(([k, v]) => [k, v.map(redact)])),
1048
+ headers: Object.fromEntries(Object.entries(request.headers).map(([k, v]) => [k, redact(v)])),
1049
+ };
1050
+ for (const { ref, value } of brokered.secrets) if (value && JSON.stringify(storedRequest).includes(value)) throw new Error(`replayFake(${opts.name}): secret ${ref} survived WebSocket handshake redaction`);
1051
+ const ips = await resolveExternalA(new URL(`https://${host}`).hostname);
1052
+ if (!ips.length) throw new Error(`replayFake(${opts.name}): could not resolve real upstream ${host}`);
1053
+ const headers: Record<string, string> = {};
1054
+ req.headers.forEach((v, k) => {
1055
+ if (!STRIP_FORWARD_HEADERS.has(k) && !k.startsWith("sec-websocket-")) headers[k] = v;
1056
+ });
1057
+ Object.assign(headers, brokered.headers);
1058
+ const { handlers, session } = await recordWebSocket({
1059
+ url: `wss://${host}${url.pathname}${url.search}`, ip: ips[0], host, headers, protocols: offered,
1060
+ request: storedRequest, match, redact, secrets: brokered.secrets,
1061
+ label: `replayFake(${opts.name}) WebSocket ${sig} connection #${state.recordedWebSockets.length + 1}`,
1062
+ captured(recording) { state.recordedWebSockets.push(recording); state.dirty = true; },
1063
+ lifetime, finished,
1064
+ });
1065
+ track(session);
1066
+ return handlers;
1067
+ };
1068
+
977
1069
  const replayHelpers = (state: CassetteState): ReplayHelpers => ({
978
1070
  replay(file: string | Cassette): ReplaySummary {
979
1071
  let loaded: Cassette;
@@ -999,12 +1091,24 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
999
1091
  if (queue) queue.push(i);
1000
1092
  else index.set(sig, [i]);
1001
1093
  }
1094
+ const websocketIndex = new Map<string, number[]>();
1095
+ if (loaded.websockets !== undefined && !Array.isArray(loaded.websockets)) throw new Error("cassette websockets must be an array");
1096
+ for (const [i, recording] of (loaded.websockets ?? []).entries()) {
1097
+ validateWebSocketRecording(recording);
1098
+ const sig = signature(recording.request, `WebSocket connection #${i + 1}`);
1099
+ const queue = websocketIndex.get(sig);
1100
+ if (queue) queue.push(i); else websocketIndex.set(sig, [i]);
1101
+ }
1102
+ if (state.websocketSessions.size) throw new Error("close active WebSockets before loading a cassette");
1103
+ state.websocketIndex = websocketIndex;
1104
+ state.websocketCursor = new Map();
1105
+ state.websocketErrors = [];
1002
1106
  // Mutate the live state object (the handler reads the same
1003
1107
  // reference); reset the cursor so replay starts from the top.
1004
1108
  state.cassette = loaded;
1005
1109
  state.index = index;
1006
1110
  state.cursor = new Map();
1007
- return { cassette: label, host: loaded.host, interactions: loaded.interactions.length };
1111
+ return { cassette: label, host: loaded.host, interactions: loaded.interactions.length, ...(loaded.websockets ? { websockets: loaded.websockets.length } : {}) };
1008
1112
  },
1009
1113
  dump(): Cassette {
1010
1114
  return structuredClone({
@@ -1012,6 +1116,9 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
1012
1116
  fake: opts.name,
1013
1117
  host,
1014
1118
  interactions: [...state.cassette.interactions, ...state.recorded],
1119
+ ...((state.cassette.websockets?.length || state.recordedWebSockets.length) ? {
1120
+ websockets: [...(state.cassette.websockets ?? []), ...state.recordedWebSockets],
1121
+ } : {}),
1015
1122
  });
1016
1123
  },
1017
1124
  recorded(): CassetteInteraction[] {
@@ -1028,6 +1135,8 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
1028
1135
  cursor: new Map(),
1029
1136
  recorded: [],
1030
1137
  dirty: false,
1138
+ websocketIndex: new Map(), websocketCursor: new Map(), recordedWebSockets: [],
1139
+ websocketSessions: new Set(), websocketErrors: [],
1031
1140
  custom,
1032
1141
  });
1033
1142
 
@@ -1035,6 +1144,7 @@ export function replayFake<S = undefined, H extends Record<string, unknown> = {}
1035
1144
  name: opts.name,
1036
1145
  hostnames: [host],
1037
1146
  secretRefs,
1147
+ websocket,
1038
1148
  state: () => opts.state
1039
1149
  ? Promise.resolve(opts.state()).then(initialState)
1040
1150
  : initialState(undefined as S),
package/src/daemon.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { runWithBackgroundFailures } from "./harness/background-failures.js";
1
2
  import { generateHostCert as mintHostCert } from "./harness/certificate.js";
2
3
  import { componentBuildTasks, COMPONENT_RUNTIME } from "./harness/component-images.js";
3
4
  import { waitForAll } from "./harness/concurrency.js";
@@ -217,6 +218,7 @@ import {
217
218
  import { readRaw, wrap } from "./inspect.js";
218
219
  import type { Wrapped } from "./inspect.js";
219
220
  import { setRecordSecrets } from "./record-secrets.js";
221
+ import { setReplayEnvironmentMode } from "./replay-mode.js";
220
222
  import { generateId } from "./ids.js";
221
223
  import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
222
224
 
@@ -5396,10 +5398,10 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
5396
5398
  // drains) so we hoist these out of the try/catch.
5397
5399
  let outcome: { status: "passed" | "failed"; error?: RunResult["error"] } = { status: "passed" };
5398
5400
  try {
5399
- const value = await Promise.race([
5401
+ const value = await runWithBackgroundFailures(() => Promise.race([
5400
5402
  Promise.resolve(runInstrumented(fetchScope, async () => { await nativeAndroid.begin(Object.values(base.svc).filter(isAndroidApp)); return testCase.run(ctx); })),
5401
5403
  timedOut,
5402
- ]);
5404
+ ]));
5403
5405
  // Stash the return value so child cases — which fork from the snapshot
5404
5406
  // we're about to capture — can read it off ctx.parent.
5405
5407
  TEST_DATA.set(testCase.id, value);
@@ -6160,6 +6162,7 @@ export function harnessMethods(state: RouteState): MethodTable {
6160
6162
  // Optional discovery: old SDKs omit this field and are never sent the
6161
6163
  // new method. Check the loaded project, including after warm restore.
6162
6164
  setupInteractive: typeof loaded?.project.setupInteractive === "function",
6165
+ replayEnvironmentMode: true,
6163
6166
  }),
6164
6167
 
6165
6168
  // Live bootstrap progress, polled by the control plane during
@@ -6269,6 +6272,17 @@ export function harnessMethods(state: RouteState): MethodTable {
6269
6272
  return { refs: [...refs], environmentScoped: true };
6270
6273
  },
6271
6274
 
6275
+ setReplayEnvironmentMode: async (params) => {
6276
+ if (params.mode !== "test" && params.mode !== "interactive") {
6277
+ throw badRequest("mode must be test or interactive");
6278
+ }
6279
+ if (state.inFlightTest || state.inFlightBootstrap || state.inFlightProjectSetup) {
6280
+ throw conflict("cannot change replay mode while work is in progress");
6281
+ }
6282
+ setReplayEnvironmentMode(params.mode);
6283
+ return { ok: true };
6284
+ },
6285
+
6272
6286
  setRecordSecrets: async (params) => {
6273
6287
  configureSecrets(params);
6274
6288
  return { ok: true };
@@ -6364,6 +6378,8 @@ export function harnessMethods(state: RouteState): MethodTable {
6364
6378
  const tc = requireLoaded().byId.get(caseId);
6365
6379
  if (!tc) throw notFound(`unknown caseId: ${caseId}`);
6366
6380
  return exclusive(state, "inFlightTest", "another test is already running", async () => {
6381
+ // A manually invoked case also stays in replay after its recorder stops.
6382
+ setReplayEnvironmentMode("test");
6367
6383
  const result = await runOne(tc);
6368
6384
  // Recordings go out-of-band: encode and park the bundle, reply with
6369
6385
  // a size ref only. A bundle-encoding failure drops the recordings
@@ -3,6 +3,8 @@ export interface FakeWebSocket {
3
3
  readonly readyState: number;
4
4
  send(message: string | Uint8Array | ArrayBuffer): void;
5
5
  close(code?: number, reason?: string): void;
6
+ /** Abrupt transport closure (no close frame), used for recorded disconnects. */
7
+ terminate?(): void;
6
8
  }
7
9
 
8
10
  /** Returned by a fake's websocket handshake callback to accept a connection.
@@ -0,0 +1,19 @@
1
+ // The harness runs one test at a time. Transport callbacks run outside the
2
+ // test's async stack, so report their failures through its existing race.
3
+ let rejectActiveTest: ((error: Error) => void) | undefined;
4
+
5
+ export function reportBackgroundFailure(error: unknown): void {
6
+ const failure = error instanceof Error ? error : new Error(String(error));
7
+ console.error("[spectest] background callback failed:", failure);
8
+ rejectActiveTest?.(failure);
9
+ }
10
+
11
+ export async function runWithBackgroundFailures<T>(run: () => Promise<T>): Promise<T> {
12
+ if (rejectActiveTest) throw new Error("A test is already handling background failures");
13
+ const failed = new Promise<never>((_resolve, reject) => { rejectActiveTest = reject; });
14
+ try {
15
+ return await Promise.race([failed, Promise.resolve().then(run)]);
16
+ } finally {
17
+ rejectActiveTest = undefined;
18
+ }
19
+ }