@specific.dev/spectest 0.109.1 → 0.111.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 +4 -0
- package/dist/components/replay-key.js +1 -1
- package/dist/components/replay-websocket.d.ts +75 -0
- package/dist/components/replay-websocket.js +414 -0
- package/dist/components/replayFake.d.ts +21 -4
- package/dist/components/replayFake.js +115 -13
- package/dist/daemon.js +21 -5
- package/dist/fake-websocket.d.ts +2 -0
- package/dist/harness/background-failures.d.ts +2 -0
- package/dist/harness/background-failures.js +19 -0
- package/dist/harness/fake-websocket-connection.d.ts +2 -2
- package/dist/harness/fake-websocket-connection.js +61 -27
- package/dist/harness/fake-websocket-lifetime.d.ts +22 -0
- package/dist/harness/fake-websocket-lifetime.js +50 -0
- package/dist/harness/fake-websocket.js +28 -9
- package/dist/index.d.ts +12 -7
- package/dist/index.js +2 -2
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +12 -0
- package/package.json +1 -1
- package/src/components/index.ts +5 -0
- package/src/components/replay-key.ts +1 -1
- package/src/components/replay-websocket.ts +387 -0
- package/src/components/replayFake-handler.test.ts +2 -1
- package/src/components/replayFake-record.test.ts +7 -1
- package/src/components/replayFake-websocket-record.test.ts +110 -0
- package/src/components/replayFake-websocket.test.ts +264 -0
- package/src/components/replayFake.ts +125 -15
- package/src/daemon.ts +22 -5
- package/src/fake-websocket.ts +2 -0
- package/src/harness/background-failures.ts +19 -0
- package/src/harness/fake-websocket-connection.ts +45 -24
- package/src/harness/fake-websocket-lifetime.test.ts +185 -0
- package/src/harness/fake-websocket-lifetime.ts +52 -0
- package/src/harness/fake-websocket.ts +22 -10
- package/src/index.ts +9 -8
- package/src/interactive-setup.test.ts +2 -2
- package/src/replay-mode.test.ts +96 -0
- 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
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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 {
|
|
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
|
|
188
|
-
* `"
|
|
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 = ()
|
|
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
|
|
|
@@ -3479,7 +3481,7 @@ let interactiveSetupCompleted = false;
|
|
|
3479
3481
|
|
|
3480
3482
|
/**
|
|
3481
3483
|
* Run a project setup hook. Normal `setup` runs before the shared warm
|
|
3482
|
-
* snapshot; `
|
|
3484
|
+
* snapshot; `interactive.seed` runs only after it, in a fresh user start.
|
|
3483
3485
|
* Both use the same context and boot-log capture.
|
|
3484
3486
|
*
|
|
3485
3487
|
* Unlike test runs, this is NOT instrumented — no recorder, no event
|
|
@@ -3498,7 +3500,8 @@ async function runProjectSetup(hook: "setup" | "setupInteractive" = "setup"): Pr
|
|
|
3498
3500
|
}
|
|
3499
3501
|
|
|
3500
3502
|
async function runProjectSetupInner(hook: "setup" | "setupInteractive"): Promise<ProjectSetupResult> {
|
|
3501
|
-
const
|
|
3503
|
+
const project = requireLoaded().project;
|
|
3504
|
+
const setup = hook === "setup" ? project.setup : project.interactive?.seed;
|
|
3502
3505
|
if (!setup || (hook === "setupInteractive" && interactiveSetupCompleted)) {
|
|
3503
3506
|
return { ran: false, durationMs: 0 };
|
|
3504
3507
|
}
|
|
@@ -5396,10 +5399,10 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5396
5399
|
// drains) so we hoist these out of the try/catch.
|
|
5397
5400
|
let outcome: { status: "passed" | "failed"; error?: RunResult["error"] } = { status: "passed" };
|
|
5398
5401
|
try {
|
|
5399
|
-
const value = await Promise.race([
|
|
5402
|
+
const value = await runWithBackgroundFailures(() => Promise.race([
|
|
5400
5403
|
Promise.resolve(runInstrumented(fetchScope, async () => { await nativeAndroid.begin(Object.values(base.svc).filter(isAndroidApp)); return testCase.run(ctx); })),
|
|
5401
5404
|
timedOut,
|
|
5402
|
-
]);
|
|
5405
|
+
]));
|
|
5403
5406
|
// Stash the return value so child cases — which fork from the snapshot
|
|
5404
5407
|
// we're about to capture — can read it off ctx.parent.
|
|
5405
5408
|
TEST_DATA.set(testCase.id, value);
|
|
@@ -6159,7 +6162,8 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6159
6162
|
capabilities: ["prepared-images-v1"],
|
|
6160
6163
|
// Optional discovery: old SDKs omit this field and are never sent the
|
|
6161
6164
|
// new method. Check the loaded project, including after warm restore.
|
|
6162
|
-
setupInteractive: typeof loaded?.project.
|
|
6165
|
+
setupInteractive: typeof loaded?.project.interactive?.seed === "function",
|
|
6166
|
+
replayEnvironmentMode: true,
|
|
6163
6167
|
}),
|
|
6164
6168
|
|
|
6165
6169
|
// Live bootstrap progress, polled by the control plane during
|
|
@@ -6269,6 +6273,17 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6269
6273
|
return { refs: [...refs], environmentScoped: true };
|
|
6270
6274
|
},
|
|
6271
6275
|
|
|
6276
|
+
setReplayEnvironmentMode: async (params) => {
|
|
6277
|
+
if (params.mode !== "test" && params.mode !== "interactive") {
|
|
6278
|
+
throw badRequest("mode must be test or interactive");
|
|
6279
|
+
}
|
|
6280
|
+
if (state.inFlightTest || state.inFlightBootstrap || state.inFlightProjectSetup) {
|
|
6281
|
+
throw conflict("cannot change replay mode while work is in progress");
|
|
6282
|
+
}
|
|
6283
|
+
setReplayEnvironmentMode(params.mode);
|
|
6284
|
+
return { ok: true };
|
|
6285
|
+
},
|
|
6286
|
+
|
|
6272
6287
|
setRecordSecrets: async (params) => {
|
|
6273
6288
|
configureSecrets(params);
|
|
6274
6289
|
return { ok: true };
|
|
@@ -6364,6 +6379,8 @@ export function harnessMethods(state: RouteState): MethodTable {
|
|
|
6364
6379
|
const tc = requireLoaded().byId.get(caseId);
|
|
6365
6380
|
if (!tc) throw notFound(`unknown caseId: ${caseId}`);
|
|
6366
6381
|
return exclusive(state, "inFlightTest", "another test is already running", async () => {
|
|
6382
|
+
// A manually invoked case also stays in replay after its recorder stops.
|
|
6383
|
+
setReplayEnvironmentMode("test");
|
|
6367
6384
|
const result = await runOne(tc);
|
|
6368
6385
|
// Recordings go out-of-band: encode and park the bundle, reply with
|
|
6369
6386
|
// a size ref only. A bundle-encoding failure drops the recordings
|
package/src/fake-websocket.ts
CHANGED
|
@@ -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
|
+
}
|