@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.
Files changed (40) 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 +21 -5
  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/index.d.ts +12 -7
  18. package/dist/index.js +2 -2
  19. package/dist/replay-mode.d.ts +2 -0
  20. package/dist/replay-mode.js +12 -0
  21. package/package.json +1 -1
  22. package/src/components/index.ts +5 -0
  23. package/src/components/replay-key.ts +1 -1
  24. package/src/components/replay-websocket.ts +387 -0
  25. package/src/components/replayFake-handler.test.ts +2 -1
  26. package/src/components/replayFake-record.test.ts +7 -1
  27. package/src/components/replayFake-websocket-record.test.ts +110 -0
  28. package/src/components/replayFake-websocket.test.ts +264 -0
  29. package/src/components/replayFake.ts +125 -15
  30. package/src/daemon.ts +22 -5
  31. package/src/fake-websocket.ts +2 -0
  32. package/src/harness/background-failures.ts +19 -0
  33. package/src/harness/fake-websocket-connection.ts +45 -24
  34. package/src/harness/fake-websocket-lifetime.test.ts +185 -0
  35. package/src/harness/fake-websocket-lifetime.ts +52 -0
  36. package/src/harness/fake-websocket.ts +22 -10
  37. package/src/index.ts +9 -8
  38. package/src/interactive-setup.test.ts +2 -2
  39. package/src/replay-mode.test.ts +96 -0
  40. package/src/replay-mode.ts +14 -0
@@ -0,0 +1,12 @@
1
+ // Environment state: travels with memory snapshots, independently of the
2
+ // per-case event recorder. Shared/warm baselines must never record upstream.
3
+ // The control plane enables recording only on an interactive environment,
4
+ // after capturing/restoring the shared baseline and before interactive setup.
5
+ let mode = "test";
6
+ export function setReplayEnvironmentMode(value) {
7
+ mode = value;
8
+ }
9
+ export function resolveReplayMode(requested = "auto") {
10
+ // Even an explicit record option cannot send test traffic to real providers.
11
+ return mode === "test" || requested === "replay" ? "replay" : "record";
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.109.1",
3
+ "version": "0.111.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -88,6 +88,11 @@ export {
88
88
  type ReplayFakeContext,
89
89
  type ReplayHandler,
90
90
  type ReplayKey,
91
+ type WebSocketKey,
92
+ type ReplayWebSocketMessage,
93
+ type CassetteWebSocket,
94
+ type CassetteWebSocketMessage,
95
+ type WebSocketTiming,
91
96
  type ReplayRequest,
92
97
  type MatchKey,
93
98
  type JsonValue,
@@ -34,7 +34,7 @@ export type ReplayKey = (request: ReplayRequest) => MatchKey;
34
34
  /** Stream a typed, length-delimited canonical representation into the hash.
35
35
  * Never call toJSON: unsupported values must fail rather than silently lose
36
36
  * identity fields. In particular, an async callback must not become `{}`. */
37
- function hashKey(key: MatchKey): string {
37
+ export function hashKey(key: MatchKey): string {
38
38
  const hash = createHash("sha256");
39
39
  const ancestors = new Set<object>();
40
40
  const visit = (value: unknown): void => {
@@ -0,0 +1,387 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import type WS from "ws";
5
+ import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
6
+ import type { CassetteRequest } from "./replayFake.js";
7
+ import { FakeWebSocketLifetime, bindWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
8
+ import { hashKey, type MatchKey } from "./replay-key.js";
9
+
10
+ // Bun's built-in ws shim does not support the external-DNS lookup option.
11
+ const require = createRequire(import.meta.url);
12
+ const { WebSocket } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
13
+ const MAX_BUFFER = 16 * 1024 * 1024;
14
+ const MAX_PENDING = 1024;
15
+ export type ReplayWebSocketMessage = string | Uint8Array;
16
+ export type WebSocketKey<S = unknown> =
17
+ | ((message: ReplayWebSocketMessage) => MatchKey)
18
+ | { initial: S; reduce(state: S, message: ReplayWebSocketMessage): { state: S; key: MatchKey } };
19
+
20
+ export interface WebSocketTiming {
21
+ /** Number of emitted input keys required. Zero means connection opening. */
22
+ afterInput: number;
23
+ /** Milliseconds since that input key (or opening). */
24
+ delayMs: number;
25
+ }
26
+ export interface CassetteWebSocketMessage extends WebSocketTiming {
27
+ type: "text" | "binary";
28
+ /** UTF-8 text or base64 binary. */
29
+ data: string;
30
+ }
31
+ export interface CassetteWebSocket {
32
+ request: CassetteRequest;
33
+ protocol?: string;
34
+ inputs: string[];
35
+ messages: CassetteWebSocketMessage[];
36
+ /** Absent while recording a connection that is still open. */
37
+ close?: WebSocketTiming & { initiator: "client" | "server"; code: number; reason: string };
38
+ }
39
+
40
+ export function validateWebSocketKey<S>(key: WebSocketKey<S> | undefined): void {
41
+ if (key === undefined || typeof key === "function") return;
42
+ if (!key || typeof key !== "object" || !("initial" in key) || typeof key.reduce !== "function") {
43
+ throw new Error("websocketKey must be a synchronous function or { initial, reduce }");
44
+ }
45
+ structuredClone(key.initial);
46
+ }
47
+
48
+ /** Clone once per connection; never retain incoming payloads. */
49
+ export function websocketMatcher<S>(key?: WebSocketKey<S>): (message: ReplayWebSocketMessage) => string | null {
50
+ let state = key && typeof key !== "function" ? structuredClone(key.initial) : undefined;
51
+ return message => {
52
+ if (!key) return createHash("sha256").update(typeof message === "string" ? "text:" : "binary:").update(message).digest("hex");
53
+ let value: MatchKey;
54
+ if (typeof key === "function") value = key(message);
55
+ else {
56
+ const next = key.reduce(state as S, message);
57
+ if (next instanceof Promise) {
58
+ void next.catch(() => {});
59
+ throw new Error("websocketKey reducer must be synchronous");
60
+ }
61
+ if (!next || typeof next !== "object" || !("state" in next) || !("key" in next)
62
+ || ("then" in next && typeof next.then === "function")) {
63
+ throw new Error("websocketKey reducer must return { state, key }");
64
+ }
65
+ value = next.key;
66
+ const signature = value === null ? null : hashKey(value);
67
+ state = next.state;
68
+ return signature;
69
+ }
70
+ return value === null ? null : hashKey(value);
71
+ };
72
+ }
73
+
74
+ export function validateWebSocketRecording(record: CassetteWebSocket): void {
75
+ if (!record || !record.request || !Array.isArray(record.inputs)
76
+ || !record.inputs.every(k => typeof k === "string" && /^[a-f0-9]{64}$/.test(k))
77
+ || !Array.isArray(record.messages)) {
78
+ throw new Error("invalid WebSocket recording: expected request, input hashes and messages");
79
+ }
80
+ for (const message of record.messages) {
81
+ if (!message || typeof message.data !== "string" || !["text", "binary"].includes(message.type)) {
82
+ throw new Error("invalid WebSocket output message");
83
+ }
84
+ if (message.type === "binary" && !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(message.data)) {
85
+ throw new Error("invalid base64 WebSocket output");
86
+ }
87
+ if (Buffer.byteLength(message.data, message.type === "binary" ? "base64" : "utf8") > MAX_BUFFER) {
88
+ throw new Error("WebSocket output buffer exceeded");
89
+ }
90
+ }
91
+ let lastBarrier = 0;
92
+ let lastDelay = 0;
93
+ for (const event of [...record.messages, ...(record.close ? [record.close] : [])]) {
94
+ if (!Number.isInteger(event.afterInput) || event.afterInput < lastBarrier
95
+ || event.afterInput > record.inputs.length || !Number.isFinite(event.delayMs)
96
+ || event.delayMs < 0 || event.delayMs > 2_147_483_647
97
+ || (event.afterInput === lastBarrier && event.delayMs < lastDelay)) {
98
+ throw new Error("invalid WebSocket playback timing or input dependency");
99
+ }
100
+ lastBarrier = event.afterInput;
101
+ lastDelay = event.delayMs;
102
+ }
103
+ if (record.protocol !== undefined && (typeof record.protocol !== "string"
104
+ || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(record.protocol))) {
105
+ throw new Error("invalid WebSocket subprotocol");
106
+ }
107
+ if (record.close && (!["client", "server"].includes(record.close.initiator)
108
+ || !validCloseCode(record.close.code) || typeof record.close.reason !== "string"
109
+ || Buffer.byteLength(record.close.reason) > 123 || record.close.afterInput !== record.inputs.length)) {
110
+ throw new Error("invalid WebSocket closure");
111
+ }
112
+ }
113
+
114
+ function validCloseCode(code: number): boolean {
115
+ return Number.isInteger(code) && ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999) || code === 1005 || code === 1006);
116
+ }
117
+ function closeSocket(socket: FakeWebSocket | WS, code: number, reason: string): void {
118
+ // Reserved status codes describe transport closure and cannot go on the wire.
119
+ if (code === 1006 && socket.terminate) socket.terminate();
120
+ else if (code === 1005 || code === 1006) socket.close();
121
+ else socket.close(code, reason);
122
+ }
123
+
124
+ export interface WebSocketSession {
125
+ cancel(): void;
126
+ }
127
+
128
+ export function replayWebSocket(args: {
129
+ recording: CassetteWebSocket;
130
+ match: (message: ReplayWebSocketMessage) => string | null;
131
+ label: string;
132
+ timeoutMs: number;
133
+ lifetime?: FakeWebSocketLifetime;
134
+ finished(session: WebSocketSession, error?: Error): void;
135
+ }): { handlers: FakeWebSocketHandlers; session: WebSocketSession } {
136
+ const { recording: record, label } = args;
137
+ const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
138
+ let detach = () => {};
139
+ let socket: FakeWebSocket | undefined;
140
+ let position = 0;
141
+ let output = 0;
142
+ const barriers: number[] = [];
143
+ let timer: ReturnType<typeof setTimeout> | undefined;
144
+ let deadline: ReturnType<typeof setTimeout> | undefined;
145
+ let stopped = false;
146
+ let sentClose = false;
147
+ const diagnostic = (detail: string) => new Error(`${label}, input #${position + 1}: ${detail}`);
148
+ const clear = () => { clearTimeout(timer); clearTimeout(deadline); };
149
+ const finish = (error?: Error) => {
150
+ if (stopped) return;
151
+ stopped = true;
152
+ clear();
153
+ detach();
154
+ lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
155
+ args.finished(session, error);
156
+ };
157
+ const fail = (error: Error) => {
158
+ finish(error);
159
+ socket?.close(1011, "WebSocket replay failed");
160
+ };
161
+ const missing = () => diagnostic(`required input never arrived; expected ${record.inputs[position]}`);
162
+ const armDeadline = () => {
163
+ clearTimeout(deadline);
164
+ if (position < record.inputs.length) deadline = setTimeout(() => fail(missing()), args.timeoutMs);
165
+ };
166
+ const schedule = () => {
167
+ if (stopped || !socket || sentClose) return;
168
+ clearTimeout(timer);
169
+ const event = (record.messages[output] as CassetteWebSocketMessage | undefined) ?? (record.close?.initiator === "server" ? record.close : undefined);
170
+ if (!event || barriers[event.afterInput] === undefined) return;
171
+ const delay = Math.max(0, barriers[event.afterInput] + event.delayMs - performance.now());
172
+ timer = setTimeout(() => {
173
+ if (stopped) return;
174
+ try {
175
+ if ("data" in event) {
176
+ socket!.send(event.type === "text" ? event.data : new Uint8Array(Buffer.from(event.data, "base64")));
177
+ output++;
178
+ schedule();
179
+ } else {
180
+ sentClose = true;
181
+ closeSocket(socket!, event.code, event.reason);
182
+ }
183
+ } catch (error) { fail(diagnostic(`playback failed: ${(error as Error).message}`)); }
184
+ }, Math.min(delay, 2_147_483_647));
185
+ };
186
+ const session: WebSocketSession = {
187
+ cancel() { finish(); socket?.close(1001, "replay cleanup"); },
188
+ };
189
+ deadline = setTimeout(() => fail(diagnostic("connection never opened")), args.timeoutMs);
190
+ const handlers: FakeWebSocketHandlers = {
191
+ protocol: record.protocol,
192
+ open(ws) {
193
+ socket = ws;
194
+ if (stopped) { ws.close(1001, "replay cancelled"); return; }
195
+ barriers[0] = performance.now(); armDeadline(); schedule();
196
+ },
197
+ message(_ws, message) {
198
+ if (stopped) return;
199
+ try {
200
+ const actual = args.match(message);
201
+ if (actual === null) return;
202
+ const expected = record.inputs[position];
203
+ if (expected !== actual) throw new Error(`expected ${expected ?? "<exhausted>"}, actual ${actual}`);
204
+ barriers[++position] = performance.now();
205
+ armDeadline();
206
+ schedule();
207
+ } catch (error) {
208
+ const failure = diagnostic(`matching failed: ${(error as Error).message}`);
209
+ fail(failure);
210
+ throw failure;
211
+ }
212
+ },
213
+ close(_ws, code, reason) {
214
+ if (stopped) return;
215
+ let error: Error | undefined;
216
+ if (position < record.inputs.length) error = missing();
217
+ else if (output < record.messages.length || (record.close?.initiator === "server" && !sentClose)) error = diagnostic("unexpected connection closure before playback completed");
218
+ // A peer acknowledging our close need not echo its reason (Node's native
219
+ // WebSocket omits it). Only match the reason for client-initiated closes.
220
+ else if (record.close && (code !== record.close.code || (record.close.initiator === "client" && reason !== record.close.reason))) error = diagnostic(`unexpected connection closure: expected code ${record.close.code}, actual ${code}${record.close.initiator === "client" ? `; expected reason ${JSON.stringify(record.close.reason)}, actual ${JSON.stringify(reason)}` : ""}`);
221
+ finish(error);
222
+ },
223
+ };
224
+ detach = lifetime.onEnd(event => {
225
+ if (event.kind === "closed" && socket) handlers.close!(socket, event.code, event.reason);
226
+ else session.cancel();
227
+ });
228
+ return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
229
+ }
230
+
231
+ /** Open upstream before accepting downstream, retaining only a bounded opening queue. */
232
+ export async function recordWebSocket(args: {
233
+ url: string;
234
+ ip: string;
235
+ host: string;
236
+ headers: Record<string, string>;
237
+ protocols: string[];
238
+ request: CassetteRequest;
239
+ match: (message: ReplayWebSocketMessage) => string | null;
240
+ redact(value: string): string;
241
+ secrets: { ref: string; value: string }[];
242
+ label: string;
243
+ captured(record: CassetteWebSocket): void;
244
+ lifetime?: FakeWebSocketLifetime;
245
+ finished(session: WebSocketSession, error?: Error): void;
246
+ }): Promise<{ handlers: FakeWebSocketHandlers; session: WebSocketSession }> {
247
+ const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
248
+ if (lifetime.ended) throw new Error("WebSocket handshake aborted");
249
+ let detach = () => {};
250
+ const upstream = new WebSocket(args.url, args.protocols, {
251
+ headers: args.headers, maxPayload: MAX_BUFFER,
252
+ handshakeTimeout: 10_000,
253
+ lookup: ((_hostname: string, options: { all?: boolean }, callback: Function) => {
254
+ if (options.all) callback(null, [{ address: args.ip, family: 4 }]);
255
+ else callback(null, args.ip, 4);
256
+ }) as any,
257
+ });
258
+ const record: CassetteWebSocket = { request: args.request, inputs: [], messages: [] };
259
+ let socket: FakeWebSocket | undefined;
260
+ let anchor = performance.now();
261
+ let stopped = false;
262
+ let clientClosed = false;
263
+ let upstreamClosure: { code: number; reason: string } | undefined;
264
+ let queue: ReplayWebSocketMessage[] = [];
265
+ let queuedBytes = 0;
266
+ let openingTimer: ReturnType<typeof setTimeout> | undefined;
267
+ let opened = false;
268
+ let rejectOpen: (error: Error) => void = () => {};
269
+ const timing = () => ({ afterInput: record.inputs.length, delayMs: Math.max(0, performance.now() - anchor) });
270
+ const scrub = (value: string) => {
271
+ const result = args.redact(value);
272
+ for (const secret of args.secrets) if (secret.value && result.includes(secret.value)) throw new Error(`secret ${secret.ref} survived WebSocket redaction`);
273
+ return result;
274
+ };
275
+ const finish = (error?: Error) => {
276
+ if (stopped) return;
277
+ stopped = true;
278
+ clearTimeout(openingTimer);
279
+ detach();
280
+ queue = [];
281
+ upstream.terminate();
282
+ lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
283
+ args.finished(session, error);
284
+ };
285
+ const fail = (error: unknown) => {
286
+ if (stopped) return;
287
+ const failure = new Error(`${args.label}: ${(error as Error).message}`);
288
+ rejectOpen(failure);
289
+ finish(failure);
290
+ socket?.close(1011, "WebSocket recording failed");
291
+ };
292
+ const session: WebSocketSession = {
293
+ cancel() { rejectOpen(new Error("WebSocket handshake aborted")); finish(); socket?.close(1001, "recording cleanup"); },
294
+ };
295
+ upstream.on("error", fail);
296
+ upstream.on("message", (data, binary) => {
297
+ if (stopped || clientClosed) return;
298
+ try {
299
+ const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
300
+ const message = binary ? new Uint8Array(bytes) : bytes.toString();
301
+ // Redact bytes too: base64 encoding must not hide a brokered secret.
302
+ const redacted = binary ? redactBinary(bytes, args.redact, args.secrets) : scrub(bytes.toString());
303
+ record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
304
+ if (socket) socket.send(message);
305
+ else {
306
+ queuedBytes += bytes.length;
307
+ if (queue.length >= MAX_PENDING || queuedBytes > MAX_BUFFER) throw new Error("WebSocket opening buffer exceeded");
308
+ queue.push(message);
309
+ }
310
+ } catch (error) { fail(error); }
311
+ });
312
+ upstream.on("close", (code, reason) => {
313
+ if (stopped) return;
314
+ if (!opened) { fail(new Error("upstream closed before handshake completed")); return; }
315
+ try {
316
+ if (!clientClosed) {
317
+ upstreamClosure = { code, reason: reason.toString() };
318
+ record.close = { ...timing(), initiator: "server", code, reason: scrub(reason.toString()) };
319
+ }
320
+ if (socket) { closeSocket(socket, code, reason.toString()); finish(); }
321
+ // If the upstream closes before downstream open, open() flushes the queue.
322
+ } catch (error) { fail(error); }
323
+ });
324
+ const opening = new Promise<void>((resolve, reject) => {
325
+ rejectOpen = reject;
326
+ upstream.once("open", () => {
327
+ try {
328
+ opened = true;
329
+ anchor = performance.now();
330
+ if (upstream.protocol) record.protocol = scrub(upstream.protocol);
331
+ args.captured(record);
332
+ openingTimer = setTimeout(() => fail(new Error("downstream did not open")), 10_000);
333
+ resolve();
334
+ } catch (error) { fail(error); }
335
+ });
336
+ });
337
+ const handlers: FakeWebSocketHandlers = {
338
+ open(ws) {
339
+ socket = ws;
340
+ clearTimeout(openingTimer);
341
+ if (stopped) { ws.close(1011, "WebSocket recording failed"); return; }
342
+ try {
343
+ for (const message of queue) ws.send(message);
344
+ queue = []; queuedBytes = 0;
345
+ if (upstreamClosure) { closeSocket(ws, upstreamClosure.code, upstreamClosure.reason); finish(); }
346
+ } catch (error) { fail(error); }
347
+ },
348
+ message(_ws, message) {
349
+ if (stopped) return;
350
+ try {
351
+ const signature = args.match(typeof message === "string" ? message : Uint8Array.from(message));
352
+ if (signature !== null) { record.inputs.push(signature); anchor = performance.now(); }
353
+ const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
354
+ if (size + upstream.bufferedAmount > MAX_BUFFER) throw new Error("WebSocket upstream buffer exceeded");
355
+ if (upstream.readyState !== WebSocket.OPEN) throw new Error("upstream is not open");
356
+ upstream.send(message);
357
+ } catch (error) { fail(error); throw error; }
358
+ },
359
+ close(_ws, code, reason) {
360
+ if (stopped || clientClosed) return;
361
+ try {
362
+ clientClosed = true;
363
+ record.close = { ...timing(), initiator: "client", code, reason: scrub(reason) };
364
+ closeSocket(upstream, code, reason);
365
+ openingTimer = setTimeout(() => finish(), 1_000);
366
+ } catch (error) { fail(error); }
367
+ },
368
+ };
369
+ detach = lifetime.onEnd(event => {
370
+ if (event.kind === "closed" && socket) handlers.close!(socket, event.code, event.reason);
371
+ else session.cancel();
372
+ });
373
+ await opening;
374
+ handlers.protocol = upstream.protocol || undefined;
375
+ return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
376
+ }
377
+
378
+ function redactBinary(bytes: Buffer, redact: (text: string) => string, secrets: { ref: string; value: string }[]): string {
379
+ // Preserve arbitrary bytes; textual patterns can only safely apply to valid UTF-8.
380
+ let output = bytes;
381
+ try {
382
+ const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
383
+ output = Buffer.from(redact(text));
384
+ } catch { /* Binary payload; fail closed for embedded credentials below. */ }
385
+ for (const { ref, value } of secrets) if (value && output.includes(Buffer.from(value))) throw new Error(`secret ${ref} survived binary WebSocket redaction`);
386
+ return output.toString("base64");
387
+ }
@@ -163,9 +163,10 @@ describe("scripted behavior on replay fakes", () => {
163
163
  extends: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
164
164
  compilerOptions: {
165
165
  noEmit: true,
166
+ rootDir: fileURLToPath(new URL("../../../", import.meta.url)),
166
167
  typeRoots: [fileURLToPath(new URL("../../node_modules/@types", import.meta.url))],
167
168
  },
168
- include: [fileURLToPath(import.meta.url)],
169
+ include: [fileURLToPath(import.meta.url), fileURLToPath(new URL("./replayFake-websocket.test.ts", import.meta.url))],
169
170
  exclude: [],
170
171
  }));
171
172
  const child = Bun.spawn([process.execPath, fileURLToPath(import.meta.resolve("typescript/bin/tsc")), "-p", config], {
@@ -44,6 +44,8 @@ test("recorded bodies and redaction survive; replay uses default order or an exp
44
44
  } }));
45
45
 
46
46
  const { replayFake } = await import(${moduleUrl("./replayFake.ts")});
47
+ const { setReplayEnvironmentMode } = await import(${moduleUrl("../replay-mode.ts")});
48
+ setReplayEnvironmentMode("interactive");
47
49
  const { setRecordSecrets } = await import(${moduleUrl("../record-secrets.ts")});
48
50
  setRecordSecrets({ API_KEY: "recording-secret" });
49
51
  const options = {
@@ -126,7 +128,8 @@ test("recorded bodies and redaction survive; replay uses default order or an exp
126
128
  const mixedCassette = mixedHelpers.dump();
127
129
  assert.equal(mixedCassette.interactions[0].request.body, "delegated");
128
130
  mixedHelpers.replay(mixedCassette);
129
- startRecording(); // Enter a test: auto now replays instead of forwarding.
131
+ setReplayEnvironmentMode("test");
132
+ startRecording();
130
133
  try {
131
134
  assert.equal(await (await send("scripted")).text(), "scripted response");
132
135
  assert.equal(await (await send("delegated")).text(), "response");
@@ -137,6 +140,9 @@ test("recorded bodies and redaction survive; replay uses default order or an exp
137
140
  } finally {
138
141
  stopRecording();
139
142
  }
143
+ // Background work after the case must still fail closed.
144
+ assert.equal((await send("delegated")).status, 599);
145
+ assert.equal(upstreamHeaders.length, 4);
140
146
  });
141
147
  `;
142
148
  const dir = await mkdtemp(join(tmpdir(), "spectest-replay-record-"));
@@ -0,0 +1,110 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { execFileSync } from "node:child_process";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ // A real TLS upstream and real ingress; only external DNS is replaced. Run in
8
+ // a child so both the resolver mock and local CA stay isolated from other tests.
9
+ test("record/replay through real WSS and ingress brokers credentials, retains subprotocol and client close", async () => {
10
+ const dir = await mkdtemp(join(tmpdir(), "spectest-ws-record-"));
11
+ const module = (file: string) => JSON.stringify(new URL(file, import.meta.url).href);
12
+ try {
13
+ const key = join(dir, "key.pem"), cert = join(dir, "cert.pem");
14
+ execFileSync("openssl", ["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", key, "-out", cert,
15
+ "-days", "1", "-subj", "/CN=voice.test", "-addext", "subjectAltName=DNS:voice.test"], { stdio: "ignore" });
16
+ const script = `
17
+ import { test, mock, expect } from "bun:test";
18
+ import { EventEmitter, once } from "node:events";
19
+ import { createServer } from "node:https";
20
+ import { readFileSync } from "node:fs";
21
+ import * as dnsPacket from ${JSON.stringify(import.meta.resolve("dns-packet"))};
22
+ import WS from ${JSON.stringify(import.meta.resolve("ws").replace(/wrapper\.mjs$/, "index.js"))};
23
+ test("round trip", async () => {
24
+ mock.module("node:dgram", () => ({ createSocket() {
25
+ const socket = new EventEmitter(); socket.close = () => {};
26
+ socket.send = (_query, _port, _host, done) => { done(); queueMicrotask(() => socket.emit("message", dnsPacket.encode({
27
+ type: "response", id: 1, answers: [{ type: "A", name: "voice.test", ttl: 60, data: "127.0.0.1" }],
28
+ }))); }; return socket;
29
+ } }));
30
+ const { replayFake } = await import(${module("./replayFake.ts")});
31
+ const { setReplayEnvironmentMode } = await import(${module("../replay-mode.ts")});
32
+ setReplayEnvironmentMode("interactive");
33
+ const { setRecordSecrets } = await import(${module("../record-secrets.ts")});
34
+ const { startIngressServer } = await import(${module("../harness/ingress-server.ts")});
35
+ const { dispatchFake } = await import(${module("../harness/fake-websocket.ts")});
36
+ const upstream = createServer({ key: readFileSync(${JSON.stringify(key)}), cert: readFileSync(${JSON.stringify(cert)}) });
37
+ const wss = new WS.WebSocketServer({ server: upstream, handleProtocols: () => "voice" });
38
+ upstream.listen(0, "127.0.0.1"); await once(upstream, "listening");
39
+ let connections = 0;
40
+ wss.on("connection", (socket, req) => {
41
+ connections++;
42
+ expect(req.headers.authorization).toBe("Bearer test-secret");
43
+ socket.send("greeting test-secret");
44
+ if (req.url === "/drop") setTimeout(() => socket.terminate(), 5);
45
+ socket.on("message", (data, binary) => socket.send(data, { binary }));
46
+ });
47
+ setRecordSecrets({ TOKEN: "test-secret" });
48
+ const options = { name: "voice", host: "voice.test:" + upstream.address().port,
49
+ inject: [{ headers: { authorization: "Bearer {{secret:TOKEN}}" } }],
50
+ key: request => ({ path: request.path, tenant: request.headers["x-tenant"] }),
51
+ };
52
+ const recorder = replayFake({ ...options, mode: "record" });
53
+ const state = await recorder.state();
54
+ const helpers = await recorder.helpers({ name: "voice", state, ctx: {} });
55
+ let active = recorder, activeState = state;
56
+ const ingress = await startIngressServer({ port: 0, hostname: "127.0.0.1", fetch: (req, server) => dispatchFake(req, server, active, activeState, {}) });
57
+ try {
58
+ async function conversation(expectedGreeting) {
59
+ const client = new WS("ws://127.0.0.1:" + ingress.port + "/live", ["other", "voice"], { headers: { authorization: "Bearer dummy", "x-tenant": "one" } });
60
+ const messages = []; client.on("message", (data, binary) => messages.push({ data: data.toString(), binary }));
61
+ await once(client, "open"); expect(client.protocol).toBe("voice");
62
+ client.send("text"); client.send(new Uint8Array([1, 2]));
63
+ for (let i = 0; messages.length < 3 && i < 200; i++) await Bun.sleep(5);
64
+ expect(messages).toEqual([{ data: expectedGreeting, binary: false }, { data: "text", binary: false }, { data: "\\x01\\x02", binary: true }]);
65
+ client.close(4002, "client-done"); await once(client, "close");
66
+ await Bun.sleep(20);
67
+ }
68
+ await conversation("greeting test-secret");
69
+ const cassette = JSON.parse(JSON.stringify(helpers.dump()));
70
+ expect(cassette.websockets).toHaveLength(1);
71
+ expect(cassette.websockets[0].close).toMatchObject({ initiator: "client", code: 4002, reason: "client-done" });
72
+ expect(cassette.websockets[0].inputs).toHaveLength(2);
73
+ expect(JSON.stringify(cassette)).not.toContain("test-secret");
74
+ expect(JSON.stringify(cassette)).not.toContain("Bearer dummy");
75
+ active = replayFake({ ...options, mode: "replay" }); activeState = await active.state();
76
+ const replay = await active.helpers({ name: "voice", state: activeState, ctx: {} });
77
+ replay.replay(cassette);
78
+ await conversation("greeting <REDACTED:TOKEN>"); expect(activeState.websocketErrors).toEqual([]);
79
+ expect(connections).toBe(1);
80
+ async function disconnect() {
81
+ const client = new WS("ws://127.0.0.1:" + ingress.port + "/drop", ["voice"], { headers: { "x-tenant": "one" } });
82
+ const closed = once(client, "close");
83
+ await once(client, "open");
84
+ expect((await closed)[0]).toBe(1006);
85
+ await Bun.sleep(20);
86
+ }
87
+ active = recorder; activeState = state;
88
+ await disconnect();
89
+ const abnormal = helpers.dump();
90
+ expect(abnormal.websockets[1].close).toMatchObject({ code: 1006, initiator: "server" });
91
+ active = replayFake({ ...options, mode: "replay" }); activeState = await active.state();
92
+ const abnormalReplay = await active.helpers({ name: "voice", state: activeState, ctx: {} });
93
+ abnormalReplay.replay(abnormal);
94
+ await disconnect(); expect(activeState.websocketErrors).toEqual([]);
95
+ expect(connections).toBe(2);
96
+ } finally {
97
+ ingress.stop(true);
98
+ for (const socket of wss.clients) socket.terminate(); wss.close(); upstream.close();
99
+ }
100
+ });
101
+ `;
102
+ const file = join(dir, "record.test.ts");
103
+ await writeFile(file, script);
104
+ const child = Bun.spawn([process.execPath, "test", file], {
105
+ env: { ...process.env, NODE_EXTRA_CA_CERTS: cert }, stdout: "pipe", stderr: "pipe",
106
+ });
107
+ const [exit, stdout, stderr] = await Promise.all([child.exited, new Response(child.stdout).text(), new Response(child.stderr).text()]);
108
+ expect(exit, stdout + stderr).toBe(0);
109
+ } finally { await rm(dir, { recursive: true, force: true }); }
110
+ }, 15_000);