@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
@@ -13,6 +13,6 @@ export { github, type GithubOptions } from "./github.js";
13
13
  export { apple, type AppleOptions } from "./apple.js";
14
14
  export { microsoft, type MicrosoftOptions } from "./microsoft.js";
15
15
  export { okta, type OktaOptions } from "./okta.js";
16
- export { replayFake, type ReplayFakeOptions, type ReplayFakeContext, type ReplayHandler, type ReplayKey, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
16
+ export { replayFake, type ReplayFakeOptions, type ReplayFakeContext, type ReplayHandler, type ReplayKey, type WebSocketKey, type ReplayWebSocketMessage, type CassetteWebSocket, type CassetteWebSocketMessage, type WebSocketTiming, type ReplayRequest, type MatchKey, type JsonValue, type InjectRule, type InjectMatch, type StringMatch, type SignConfig, type AwsSigV4Sign, type ReplayHelpers, type ReplaySummary, type Cassette, type CassetteInteraction, type CassetteRequest, type CassetteResponse, type CassetteState, } from "./replayFake.js";
17
17
  export { electron, type ElectronOptions } from "./electron.js";
18
18
  export { android, type AndroidOptions } from "./android.js";
@@ -23,5 +23,9 @@ export interface ReplayRequest {
23
23
  bytes(): Uint8Array;
24
24
  }
25
25
  export type ReplayKey = (request: ReplayRequest) => MatchKey;
26
+ /** Stream a typed, length-delimited canonical representation into the hash.
27
+ * Never call toJSON: unsupported values must fail rather than silently lose
28
+ * identity fields. In particular, an async callback must not become `{}`. */
29
+ export declare function hashKey(key: MatchKey): string;
26
30
  /** Internal only; the SDK exports the request/key types, not this function. */
27
31
  export declare function replaySignature(request: CassetteRequest, key?: ReplayKey): string;
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  /** Stream a typed, length-delimited canonical representation into the hash.
3
3
  * Never call toJSON: unsupported values must fail rather than silently lose
4
4
  * identity fields. In particular, an async callback must not become `{}`. */
5
- function hashKey(key) {
5
+ export function hashKey(key) {
6
6
  const hash = createHash("sha256");
7
7
  const ancestors = new Set();
8
8
  const visit = (value) => {
@@ -0,0 +1,75 @@
1
+ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
+ import type { CassetteRequest } from "./replayFake.js";
3
+ import { FakeWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
4
+ import { type MatchKey } from "./replay-key.js";
5
+ export type ReplayWebSocketMessage = string | Uint8Array;
6
+ export type WebSocketKey<S = unknown> = ((message: ReplayWebSocketMessage) => MatchKey) | {
7
+ initial: S;
8
+ reduce(state: S, message: ReplayWebSocketMessage): {
9
+ state: S;
10
+ key: MatchKey;
11
+ };
12
+ };
13
+ export interface WebSocketTiming {
14
+ /** Number of emitted input keys required. Zero means connection opening. */
15
+ afterInput: number;
16
+ /** Milliseconds since that input key (or opening). */
17
+ delayMs: number;
18
+ }
19
+ export interface CassetteWebSocketMessage extends WebSocketTiming {
20
+ type: "text" | "binary";
21
+ /** UTF-8 text or base64 binary. */
22
+ data: string;
23
+ }
24
+ export interface CassetteWebSocket {
25
+ request: CassetteRequest;
26
+ protocol?: string;
27
+ inputs: string[];
28
+ messages: CassetteWebSocketMessage[];
29
+ /** Absent while recording a connection that is still open. */
30
+ close?: WebSocketTiming & {
31
+ initiator: "client" | "server";
32
+ code: number;
33
+ reason: string;
34
+ };
35
+ }
36
+ export declare function validateWebSocketKey<S>(key: WebSocketKey<S> | undefined): void;
37
+ /** Clone once per connection; never retain incoming payloads. */
38
+ export declare function websocketMatcher<S>(key?: WebSocketKey<S>): (message: ReplayWebSocketMessage) => string | null;
39
+ export declare function validateWebSocketRecording(record: CassetteWebSocket): void;
40
+ export interface WebSocketSession {
41
+ cancel(): void;
42
+ }
43
+ export declare function replayWebSocket(args: {
44
+ recording: CassetteWebSocket;
45
+ match: (message: ReplayWebSocketMessage) => string | null;
46
+ label: string;
47
+ timeoutMs: number;
48
+ lifetime?: FakeWebSocketLifetime;
49
+ finished(session: WebSocketSession, error?: Error): void;
50
+ }): {
51
+ handlers: FakeWebSocketHandlers;
52
+ session: WebSocketSession;
53
+ };
54
+ /** Open upstream before accepting downstream, retaining only a bounded opening queue. */
55
+ export declare function recordWebSocket(args: {
56
+ url: string;
57
+ ip: string;
58
+ host: string;
59
+ headers: Record<string, string>;
60
+ protocols: string[];
61
+ request: CassetteRequest;
62
+ match: (message: ReplayWebSocketMessage) => string | null;
63
+ redact(value: string): string;
64
+ secrets: {
65
+ ref: string;
66
+ value: string;
67
+ }[];
68
+ label: string;
69
+ captured(record: CassetteWebSocket): void;
70
+ lifetime?: FakeWebSocketLifetime;
71
+ finished(session: WebSocketSession, error?: Error): void;
72
+ }): Promise<{
73
+ handlers: FakeWebSocketHandlers;
74
+ session: WebSocketSession;
75
+ }>;
@@ -0,0 +1,414 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join } from "node:path";
4
+ import { FakeWebSocketLifetime, bindWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
5
+ import { hashKey } from "./replay-key.js";
6
+ // Bun's built-in ws shim does not support the external-DNS lookup option.
7
+ const require = createRequire(import.meta.url);
8
+ const { WebSocket } = require(join(dirname(require.resolve("ws/package.json")), "index.js"));
9
+ const MAX_BUFFER = 16 * 1024 * 1024;
10
+ const MAX_PENDING = 1024;
11
+ export function validateWebSocketKey(key) {
12
+ if (key === undefined || typeof key === "function")
13
+ return;
14
+ if (!key || typeof key !== "object" || !("initial" in key) || typeof key.reduce !== "function") {
15
+ throw new Error("websocketKey must be a synchronous function or { initial, reduce }");
16
+ }
17
+ structuredClone(key.initial);
18
+ }
19
+ /** Clone once per connection; never retain incoming payloads. */
20
+ export function websocketMatcher(key) {
21
+ let state = key && typeof key !== "function" ? structuredClone(key.initial) : undefined;
22
+ return message => {
23
+ if (!key)
24
+ return createHash("sha256").update(typeof message === "string" ? "text:" : "binary:").update(message).digest("hex");
25
+ let value;
26
+ if (typeof key === "function")
27
+ value = key(message);
28
+ else {
29
+ const next = key.reduce(state, message);
30
+ if (next instanceof Promise) {
31
+ void next.catch(() => { });
32
+ throw new Error("websocketKey reducer must be synchronous");
33
+ }
34
+ if (!next || typeof next !== "object" || !("state" in next) || !("key" in next)
35
+ || ("then" in next && typeof next.then === "function")) {
36
+ throw new Error("websocketKey reducer must return { state, key }");
37
+ }
38
+ value = next.key;
39
+ const signature = value === null ? null : hashKey(value);
40
+ state = next.state;
41
+ return signature;
42
+ }
43
+ return value === null ? null : hashKey(value);
44
+ };
45
+ }
46
+ export function validateWebSocketRecording(record) {
47
+ if (!record || !record.request || !Array.isArray(record.inputs)
48
+ || !record.inputs.every(k => typeof k === "string" && /^[a-f0-9]{64}$/.test(k))
49
+ || !Array.isArray(record.messages)) {
50
+ throw new Error("invalid WebSocket recording: expected request, input hashes and messages");
51
+ }
52
+ for (const message of record.messages) {
53
+ if (!message || typeof message.data !== "string" || !["text", "binary"].includes(message.type)) {
54
+ throw new Error("invalid WebSocket output message");
55
+ }
56
+ if (message.type === "binary" && !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(message.data)) {
57
+ throw new Error("invalid base64 WebSocket output");
58
+ }
59
+ if (Buffer.byteLength(message.data, message.type === "binary" ? "base64" : "utf8") > MAX_BUFFER) {
60
+ throw new Error("WebSocket output buffer exceeded");
61
+ }
62
+ }
63
+ let lastBarrier = 0;
64
+ let lastDelay = 0;
65
+ for (const event of [...record.messages, ...(record.close ? [record.close] : [])]) {
66
+ if (!Number.isInteger(event.afterInput) || event.afterInput < lastBarrier
67
+ || event.afterInput > record.inputs.length || !Number.isFinite(event.delayMs)
68
+ || event.delayMs < 0 || event.delayMs > 2_147_483_647
69
+ || (event.afterInput === lastBarrier && event.delayMs < lastDelay)) {
70
+ throw new Error("invalid WebSocket playback timing or input dependency");
71
+ }
72
+ lastBarrier = event.afterInput;
73
+ lastDelay = event.delayMs;
74
+ }
75
+ if (record.protocol !== undefined && (typeof record.protocol !== "string"
76
+ || !/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(record.protocol))) {
77
+ throw new Error("invalid WebSocket subprotocol");
78
+ }
79
+ if (record.close && (!["client", "server"].includes(record.close.initiator)
80
+ || !validCloseCode(record.close.code) || typeof record.close.reason !== "string"
81
+ || Buffer.byteLength(record.close.reason) > 123 || record.close.afterInput !== record.inputs.length)) {
82
+ throw new Error("invalid WebSocket closure");
83
+ }
84
+ }
85
+ function validCloseCode(code) {
86
+ return Number.isInteger(code) && ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999) || code === 1005 || code === 1006);
87
+ }
88
+ function closeSocket(socket, code, reason) {
89
+ // Reserved status codes describe transport closure and cannot go on the wire.
90
+ if (code === 1006 && socket.terminate)
91
+ socket.terminate();
92
+ else if (code === 1005 || code === 1006)
93
+ socket.close();
94
+ else
95
+ socket.close(code, reason);
96
+ }
97
+ export function replayWebSocket(args) {
98
+ const { recording: record, label } = args;
99
+ const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
100
+ let detach = () => { };
101
+ let socket;
102
+ let position = 0;
103
+ let output = 0;
104
+ const barriers = [];
105
+ let timer;
106
+ let deadline;
107
+ let stopped = false;
108
+ let sentClose = false;
109
+ const diagnostic = (detail) => new Error(`${label}, input #${position + 1}: ${detail}`);
110
+ const clear = () => { clearTimeout(timer); clearTimeout(deadline); };
111
+ const finish = (error) => {
112
+ if (stopped)
113
+ return;
114
+ stopped = true;
115
+ clear();
116
+ detach();
117
+ lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
118
+ args.finished(session, error);
119
+ };
120
+ const fail = (error) => {
121
+ finish(error);
122
+ socket?.close(1011, "WebSocket replay failed");
123
+ };
124
+ const missing = () => diagnostic(`required input never arrived; expected ${record.inputs[position]}`);
125
+ const armDeadline = () => {
126
+ clearTimeout(deadline);
127
+ if (position < record.inputs.length)
128
+ deadline = setTimeout(() => fail(missing()), args.timeoutMs);
129
+ };
130
+ const schedule = () => {
131
+ if (stopped || !socket || sentClose)
132
+ return;
133
+ clearTimeout(timer);
134
+ const event = record.messages[output] ?? (record.close?.initiator === "server" ? record.close : undefined);
135
+ if (!event || barriers[event.afterInput] === undefined)
136
+ return;
137
+ const delay = Math.max(0, barriers[event.afterInput] + event.delayMs - performance.now());
138
+ timer = setTimeout(() => {
139
+ if (stopped)
140
+ return;
141
+ try {
142
+ if ("data" in event) {
143
+ socket.send(event.type === "text" ? event.data : new Uint8Array(Buffer.from(event.data, "base64")));
144
+ output++;
145
+ schedule();
146
+ }
147
+ else {
148
+ sentClose = true;
149
+ closeSocket(socket, event.code, event.reason);
150
+ }
151
+ }
152
+ catch (error) {
153
+ fail(diagnostic(`playback failed: ${error.message}`));
154
+ }
155
+ }, Math.min(delay, 2_147_483_647));
156
+ };
157
+ const session = {
158
+ cancel() { finish(); socket?.close(1001, "replay cleanup"); },
159
+ };
160
+ deadline = setTimeout(() => fail(diagnostic("connection never opened")), args.timeoutMs);
161
+ const handlers = {
162
+ protocol: record.protocol,
163
+ open(ws) {
164
+ socket = ws;
165
+ if (stopped) {
166
+ ws.close(1001, "replay cancelled");
167
+ return;
168
+ }
169
+ barriers[0] = performance.now();
170
+ armDeadline();
171
+ schedule();
172
+ },
173
+ message(_ws, message) {
174
+ if (stopped)
175
+ return;
176
+ try {
177
+ const actual = args.match(message);
178
+ if (actual === null)
179
+ return;
180
+ const expected = record.inputs[position];
181
+ if (expected !== actual)
182
+ throw new Error(`expected ${expected ?? "<exhausted>"}, actual ${actual}`);
183
+ barriers[++position] = performance.now();
184
+ armDeadline();
185
+ schedule();
186
+ }
187
+ catch (error) {
188
+ const failure = diagnostic(`matching failed: ${error.message}`);
189
+ fail(failure);
190
+ throw failure;
191
+ }
192
+ },
193
+ close(_ws, code, reason) {
194
+ if (stopped)
195
+ return;
196
+ let error;
197
+ if (position < record.inputs.length)
198
+ error = missing();
199
+ else if (output < record.messages.length || (record.close?.initiator === "server" && !sentClose))
200
+ error = diagnostic("unexpected connection closure before playback completed");
201
+ // A peer acknowledging our close need not echo its reason (Node's native
202
+ // WebSocket omits it). Only match the reason for client-initiated closes.
203
+ else if (record.close && (code !== record.close.code || (record.close.initiator === "client" && reason !== record.close.reason)))
204
+ 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)}` : ""}`);
205
+ finish(error);
206
+ },
207
+ };
208
+ detach = lifetime.onEnd(event => {
209
+ if (event.kind === "closed" && socket)
210
+ handlers.close(socket, event.code, event.reason);
211
+ else
212
+ session.cancel();
213
+ });
214
+ return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
215
+ }
216
+ /** Open upstream before accepting downstream, retaining only a bounded opening queue. */
217
+ export async function recordWebSocket(args) {
218
+ const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
219
+ if (lifetime.ended)
220
+ throw new Error("WebSocket handshake aborted");
221
+ let detach = () => { };
222
+ const upstream = new WebSocket(args.url, args.protocols, {
223
+ headers: args.headers, maxPayload: MAX_BUFFER,
224
+ handshakeTimeout: 10_000,
225
+ lookup: ((_hostname, options, callback) => {
226
+ if (options.all)
227
+ callback(null, [{ address: args.ip, family: 4 }]);
228
+ else
229
+ callback(null, args.ip, 4);
230
+ }),
231
+ });
232
+ const record = { request: args.request, inputs: [], messages: [] };
233
+ let socket;
234
+ let anchor = performance.now();
235
+ let stopped = false;
236
+ let clientClosed = false;
237
+ let upstreamClosure;
238
+ let queue = [];
239
+ let queuedBytes = 0;
240
+ let openingTimer;
241
+ let opened = false;
242
+ let rejectOpen = () => { };
243
+ const timing = () => ({ afterInput: record.inputs.length, delayMs: Math.max(0, performance.now() - anchor) });
244
+ const scrub = (value) => {
245
+ const result = args.redact(value);
246
+ for (const secret of args.secrets)
247
+ if (secret.value && result.includes(secret.value))
248
+ throw new Error(`secret ${secret.ref} survived WebSocket redaction`);
249
+ return result;
250
+ };
251
+ const finish = (error) => {
252
+ if (stopped)
253
+ return;
254
+ stopped = true;
255
+ clearTimeout(openingTimer);
256
+ detach();
257
+ queue = [];
258
+ upstream.terminate();
259
+ lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
260
+ args.finished(session, error);
261
+ };
262
+ const fail = (error) => {
263
+ if (stopped)
264
+ return;
265
+ const failure = new Error(`${args.label}: ${error.message}`);
266
+ rejectOpen(failure);
267
+ finish(failure);
268
+ socket?.close(1011, "WebSocket recording failed");
269
+ };
270
+ const session = {
271
+ cancel() { rejectOpen(new Error("WebSocket handshake aborted")); finish(); socket?.close(1001, "recording cleanup"); },
272
+ };
273
+ upstream.on("error", fail);
274
+ upstream.on("message", (data, binary) => {
275
+ if (stopped || clientClosed)
276
+ return;
277
+ try {
278
+ const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
279
+ const message = binary ? new Uint8Array(bytes) : bytes.toString();
280
+ // Redact bytes too: base64 encoding must not hide a brokered secret.
281
+ const redacted = binary ? redactBinary(bytes, args.redact, args.secrets) : scrub(bytes.toString());
282
+ record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
283
+ if (socket)
284
+ socket.send(message);
285
+ else {
286
+ queuedBytes += bytes.length;
287
+ if (queue.length >= MAX_PENDING || queuedBytes > MAX_BUFFER)
288
+ throw new Error("WebSocket opening buffer exceeded");
289
+ queue.push(message);
290
+ }
291
+ }
292
+ catch (error) {
293
+ fail(error);
294
+ }
295
+ });
296
+ upstream.on("close", (code, reason) => {
297
+ if (stopped)
298
+ return;
299
+ if (!opened) {
300
+ fail(new Error("upstream closed before handshake completed"));
301
+ return;
302
+ }
303
+ try {
304
+ if (!clientClosed) {
305
+ upstreamClosure = { code, reason: reason.toString() };
306
+ record.close = { ...timing(), initiator: "server", code, reason: scrub(reason.toString()) };
307
+ }
308
+ if (socket) {
309
+ closeSocket(socket, code, reason.toString());
310
+ finish();
311
+ }
312
+ // If the upstream closes before downstream open, open() flushes the queue.
313
+ }
314
+ catch (error) {
315
+ fail(error);
316
+ }
317
+ });
318
+ const opening = new Promise((resolve, reject) => {
319
+ rejectOpen = reject;
320
+ upstream.once("open", () => {
321
+ try {
322
+ opened = true;
323
+ anchor = performance.now();
324
+ if (upstream.protocol)
325
+ record.protocol = scrub(upstream.protocol);
326
+ args.captured(record);
327
+ openingTimer = setTimeout(() => fail(new Error("downstream did not open")), 10_000);
328
+ resolve();
329
+ }
330
+ catch (error) {
331
+ fail(error);
332
+ }
333
+ });
334
+ });
335
+ const handlers = {
336
+ open(ws) {
337
+ socket = ws;
338
+ clearTimeout(openingTimer);
339
+ if (stopped) {
340
+ ws.close(1011, "WebSocket recording failed");
341
+ return;
342
+ }
343
+ try {
344
+ for (const message of queue)
345
+ ws.send(message);
346
+ queue = [];
347
+ queuedBytes = 0;
348
+ if (upstreamClosure) {
349
+ closeSocket(ws, upstreamClosure.code, upstreamClosure.reason);
350
+ finish();
351
+ }
352
+ }
353
+ catch (error) {
354
+ fail(error);
355
+ }
356
+ },
357
+ message(_ws, message) {
358
+ if (stopped)
359
+ return;
360
+ try {
361
+ const signature = args.match(typeof message === "string" ? message : Uint8Array.from(message));
362
+ if (signature !== null) {
363
+ record.inputs.push(signature);
364
+ anchor = performance.now();
365
+ }
366
+ const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
367
+ if (size + upstream.bufferedAmount > MAX_BUFFER)
368
+ throw new Error("WebSocket upstream buffer exceeded");
369
+ if (upstream.readyState !== WebSocket.OPEN)
370
+ throw new Error("upstream is not open");
371
+ upstream.send(message);
372
+ }
373
+ catch (error) {
374
+ fail(error);
375
+ throw error;
376
+ }
377
+ },
378
+ close(_ws, code, reason) {
379
+ if (stopped || clientClosed)
380
+ return;
381
+ try {
382
+ clientClosed = true;
383
+ record.close = { ...timing(), initiator: "client", code, reason: scrub(reason) };
384
+ closeSocket(upstream, code, reason);
385
+ openingTimer = setTimeout(() => finish(), 1_000);
386
+ }
387
+ catch (error) {
388
+ fail(error);
389
+ }
390
+ },
391
+ };
392
+ detach = lifetime.onEnd(event => {
393
+ if (event.kind === "closed" && socket)
394
+ handlers.close(socket, event.code, event.reason);
395
+ else
396
+ session.cancel();
397
+ });
398
+ await opening;
399
+ handlers.protocol = upstream.protocol || undefined;
400
+ return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
401
+ }
402
+ function redactBinary(bytes, redact, secrets) {
403
+ // Preserve arbitrary bytes; textual patterns can only safely apply to valid UTF-8.
404
+ let output = bytes;
405
+ try {
406
+ const text = new TextDecoder("utf8", { fatal: true, ignoreBOM: true }).decode(bytes);
407
+ output = Buffer.from(redact(text));
408
+ }
409
+ catch { /* Binary payload; fail closed for embedded credentials below. */ }
410
+ for (const { ref, value } of secrets)
411
+ if (value && output.includes(Buffer.from(value)))
412
+ throw new Error(`secret ${ref} survived binary WebSocket redaction`);
413
+ return output.toString("base64");
414
+ }
@@ -1,4 +1,6 @@
1
1
  import type { FakeContext, FakeDefinition } from "../index.js";
2
+ import { type WebSocketKey, type CassetteWebSocket, type WebSocketSession } from "./replay-websocket.js";
3
+ export type { WebSocketKey, ReplayWebSocketMessage, CassetteWebSocket, CassetteWebSocketMessage, WebSocketTiming } from "./replay-websocket.js";
2
4
  import { type ReplayKey } from "./replay-key.js";
3
5
  export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
4
6
  /** Match one request dimension. A bare string is an exact match; the
@@ -62,7 +64,7 @@ export interface ReplayFakeContext extends FakeContext {
62
64
  replay(request: Request): Promise<Response>;
63
65
  }
64
66
  export type ReplayHandler<S> = (request: Request, state: S, ctx: ReplayFakeContext) => Response | Promise<Response>;
65
- export interface ReplayFakeOptions<S = undefined, H extends Record<string, unknown> = {}> {
67
+ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unknown> = {}, W = unknown> {
66
68
  /** Stable name — the `ctx.fakes` key. */
67
69
  name: string;
68
70
  /** The REAL host to fake, e.g. `"api.stripe.com"`. The fake answers for
@@ -79,6 +81,13 @@ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unkno
79
81
  * bytes; objects compare independently of property order. No implicit
80
82
  * matching dimensions or fallback apply when a key is supplied. */
81
83
  key?: ReplayKey;
84
+ /** Complete messages (string or Uint8Array), synchronously reduced per connection.
85
+ * Return null to emit no input. Raw inputs are never stored; changing this
86
+ * matcher can require re-recording. Defaults to SHA-256(type + bytes). */
87
+ websocketKey?: WebSocketKey<W>;
88
+ /** Maximum wait for each required input key; null-producing messages do not
89
+ * extend it. Defaults to 30 seconds. */
90
+ websocketTimeoutMs?: number;
82
91
  /** Per-fake credential brokering. Rules are evaluated in
83
92
  * order; the first whose `match` matches wins (a rule without `match`
84
93
  * matches everything and shadows later rules). Applied at the
@@ -94,8 +103,9 @@ export interface ReplayFakeOptions<S = undefined, H extends Record<string, unkno
94
103
  /** Extra substrings/patterns to scrub from stored request/response
95
104
  * bodies and queries (every injected secret value is always scrubbed). */
96
105
  redactPatterns?: (string | RegExp)[];
97
- /** `"auto"` (default) records under eval and replays under test;
98
- * `"record"` / `"replay"` force a mode. */
106
+ /** `"auto"` (default) records in interactive environments and replays in
107
+ * tests. `"replay"` always replays; `"record"` requires an interactive
108
+ * environment. Test environments always replay, even between cases. */
99
109
  mode?: "auto" | "record" | "replay";
100
110
  /** Custom state, with the same lifecycle as a normal fake's state. Kept
101
111
  * separate from cassette internals; helpers and handler see only S. */
@@ -143,6 +153,7 @@ export interface Cassette {
143
153
  /** The real host this cassette mirrors (e.g. `api.stripe.com`). */
144
154
  host: string;
145
155
  interactions: CassetteInteraction[];
156
+ websockets?: CassetteWebSocket[];
146
157
  }
147
158
  /** Forked-per-test state for one replay fake. */
148
159
  export interface CassetteState {
@@ -154,6 +165,11 @@ export interface CassetteState {
154
165
  /** Interactions captured this record session. */
155
166
  recorded: CassetteInteraction[];
156
167
  dirty: boolean;
168
+ websocketIndex: Map<string, number[]>;
169
+ websocketCursor: Map<string, number>;
170
+ recordedWebSockets: CassetteWebSocket[];
171
+ websocketSessions: Set<WebSocketSession>;
172
+ websocketErrors: string[];
157
173
  }
158
174
  /** What `replay(...)` returns — a small summary that surfaces in the test
159
175
  * timeline (recorded as a `fake` event) so the run shows exactly which
@@ -165,6 +181,7 @@ export interface ReplaySummary {
165
181
  host: string;
166
182
  /** Number of interactions now available for replay. */
167
183
  interactions: number;
184
+ websockets?: number;
168
185
  }
169
186
  export interface ReplayHelpers extends Record<string, unknown> {
170
187
  /** Load a cassette into THIS fork's replay set for delegated requests,
@@ -185,6 +202,6 @@ export interface ReplayHelpers extends Record<string, unknown> {
185
202
  /** Count of interactions captured this record session. */
186
203
  count(): number;
187
204
  }
188
- export declare function replayFake<S = undefined, H extends Record<string, unknown> = {}>(opts: ReplayFakeOptions<S, H>): FakeDefinition<CassetteState & {
205
+ export declare function replayFake<S = undefined, H extends Record<string, unknown> = {}, W = unknown>(opts: ReplayFakeOptions<S, H, W>): FakeDefinition<CassetteState & {
189
206
  custom: S;
190
207
  }, ReplayHelpers & H>;