@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
|
@@ -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
|
|
98
|
-
* `"
|
|
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>;
|