@specific.dev/spectest 0.115.0 → 0.116.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/README.md +44 -0
- package/dist/components/replay-websocket.d.ts +2 -3
- package/dist/components/replay-websocket.js +19 -185
- package/dist/components/replayFake.d.ts +2 -53
- package/dist/components/replayFake.js +7 -348
- package/dist/daemon.js +5 -1
- package/dist/fake-upstream-websocket.d.ts +27 -0
- package/dist/fake-upstream-websocket.js +184 -0
- package/dist/fake-upstream.d.ts +114 -0
- package/dist/fake-upstream.js +348 -0
- package/dist/harness/fake-websocket.js +6 -3
- package/dist/harness/live-fake.d.ts +3 -0
- package/dist/harness/live-fake.js +66 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +3 -0
- package/dist/record-secrets.d.ts +1 -1
- package/dist/record-secrets.js +3 -3
- package/dist/replay-mode.d.ts +2 -0
- package/dist/replay-mode.js +6 -2
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +25 -145
- package/src/components/replayFake.ts +8 -459
- package/src/daemon.ts +4 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -0
- package/src/harness/activity.test.ts +20 -0
- package/src/harness/fake-websocket.ts +6 -3
- package/src/harness/live-fake.test.ts +227 -0
- package/src/harness/live-fake.ts +62 -0
- package/src/index.ts +19 -1
- package/src/record-secrets.ts +3 -3
- package/src/replay-mode.ts +7 -2
package/package.json
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
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
2
|
import type { FakeWebSocket, FakeWebSocketHandlers } from "../fake-websocket.js";
|
|
6
3
|
import type { CassetteRequest } from "./replayFake.js";
|
|
7
4
|
import { FakeWebSocketLifetime, bindWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
|
|
8
5
|
import { hashKey, type MatchKey } from "./replay-key.js";
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const { WebSocket } = require(join(dirname(require.resolve("ws/package.json")), "index.js")) as typeof import("ws");
|
|
13
|
-
const MAX_BUFFER = 16 * 1024 * 1024;
|
|
7
|
+
import { MAX_BUFFER, closeSocket, forwardWebSocket, type WebSocketSession } from "../fake-upstream-websocket.js";
|
|
8
|
+
export type { WebSocketSession } from "../fake-upstream-websocket.js";
|
|
14
9
|
const MAX_PENDING = 1024;
|
|
15
10
|
export type ReplayWebSocketMessage = string | Uint8Array;
|
|
16
11
|
export type WebSocketKey<S = unknown> =
|
|
@@ -181,17 +176,6 @@ export function validateWebSocketRecording(record: CassetteWebSocket): void {
|
|
|
181
176
|
function validCloseCode(code: number): boolean {
|
|
182
177
|
return Number.isInteger(code) && ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999) || code === 1005 || code === 1006);
|
|
183
178
|
}
|
|
184
|
-
function closeSocket(socket: FakeWebSocket | WS, code: number, reason: string): void {
|
|
185
|
-
// Reserved status codes describe transport closure and cannot go on the wire.
|
|
186
|
-
if (code === 1006 && socket.terminate) socket.terminate();
|
|
187
|
-
else if (code === 1005 || code === 1006) socket.close();
|
|
188
|
-
else socket.close(code, reason);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export interface WebSocketSession {
|
|
192
|
-
cancel(): void;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
179
|
export function replayWebSocket(args: {
|
|
196
180
|
recording: CassetteWebSocket;
|
|
197
181
|
match: WebSocketMatcher;
|
|
@@ -352,24 +336,12 @@ export async function recordWebSocket(args: {
|
|
|
352
336
|
lifetime?: FakeWebSocketLifetime;
|
|
353
337
|
finished(session: WebSocketSession, error?: Error): void;
|
|
354
338
|
}): Promise<{ handlers: FakeWebSocketHandlers; session: WebSocketSession }> {
|
|
355
|
-
const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
|
|
356
|
-
if (lifetime.ended) throw new Error("WebSocket handshake aborted");
|
|
357
|
-
let detach = () => {};
|
|
358
|
-
const upstream = new WebSocket(args.url, args.protocols, {
|
|
359
|
-
headers: args.headers, maxPayload: MAX_BUFFER,
|
|
360
|
-
handshakeTimeout: 10_000,
|
|
361
|
-
lookup: ((_hostname: string, options: { all?: boolean }, callback: Function) => {
|
|
362
|
-
if (options.all) callback(null, [{ address: args.ip, family: 4 }]);
|
|
363
|
-
else callback(null, args.ip, 4);
|
|
364
|
-
}) as any,
|
|
365
|
-
});
|
|
366
339
|
const record: CassetteWebSocket = { request: args.request, inputs: [], messages: [],
|
|
367
340
|
...(args.match.candidates ? { inputMode: "anchors" as const, inputOccurrences: [] } : {}),
|
|
368
341
|
};
|
|
369
342
|
const occurrences = new Map<string, number>();
|
|
370
343
|
let candidate: { key: string; count: number; at: number; index: number } | undefined;
|
|
371
344
|
let candidateIndex = 0, selectedIndex = 0;
|
|
372
|
-
let socket: FakeWebSocket | undefined;
|
|
373
345
|
let anchor = performance.now();
|
|
374
346
|
const selectCandidate = () => {
|
|
375
347
|
if (!candidate || candidate.index === selectedIndex) return;
|
|
@@ -378,131 +350,39 @@ export async function recordWebSocket(args: {
|
|
|
378
350
|
anchor = candidate.at;
|
|
379
351
|
selectedIndex = candidate.index;
|
|
380
352
|
};
|
|
381
|
-
let stopped = false;
|
|
382
|
-
let clientClosed = false;
|
|
383
|
-
let upstreamClosure: { code: number; reason: string } | undefined;
|
|
384
|
-
let queue: ReplayWebSocketMessage[] = [];
|
|
385
|
-
let queuedBytes = 0;
|
|
386
|
-
let openingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
387
|
-
let opened = false;
|
|
388
|
-
let rejectOpen: (error: Error) => void = () => {};
|
|
389
353
|
const timing = () => ({ afterInput: record.inputs.length, delayMs: Math.max(0, performance.now() - anchor) });
|
|
390
354
|
const scrub = (value: string) => {
|
|
391
355
|
const result = args.redact(value);
|
|
392
356
|
for (const secret of args.secrets) if (secret.value && result.includes(secret.value)) throw new Error(`secret ${secret.ref} survived WebSocket redaction`);
|
|
393
357
|
return result;
|
|
394
358
|
};
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
finish(failure);
|
|
410
|
-
socket?.close(1011, "WebSocket recording failed");
|
|
411
|
-
};
|
|
412
|
-
const session: WebSocketSession = {
|
|
413
|
-
cancel() { rejectOpen(new Error("WebSocket handshake aborted")); finish(); socket?.close(1001, "recording cleanup"); },
|
|
414
|
-
};
|
|
415
|
-
upstream.on("error", fail);
|
|
416
|
-
upstream.on("message", (data, binary) => {
|
|
417
|
-
if (stopped || clientClosed) return;
|
|
418
|
-
try {
|
|
419
|
-
const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data as ArrayBuffer);
|
|
420
|
-
const message = binary ? new Uint8Array(bytes) : bytes.toString();
|
|
421
|
-
// Redact bytes too: base64 encoding must not hide a brokered secret.
|
|
422
|
-
const redacted = binary ? redactBinary(bytes, args.redact, args.secrets) : scrub(bytes.toString());
|
|
423
|
-
// Apply the same redacted output reducer that replay will see.
|
|
424
|
-
if (args.match.output?.(binary ? new Uint8Array(Buffer.from(redacted, "base64")) : redacted)) selectCandidate();
|
|
425
|
-
record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
|
|
426
|
-
if (socket) socket.send(message);
|
|
427
|
-
else {
|
|
428
|
-
queuedBytes += bytes.length;
|
|
429
|
-
if (queue.length >= MAX_PENDING || queuedBytes > MAX_BUFFER) throw new Error("WebSocket opening buffer exceeded");
|
|
430
|
-
queue.push(message);
|
|
431
|
-
}
|
|
432
|
-
} catch (error) { fail(error); }
|
|
433
|
-
});
|
|
434
|
-
upstream.on("close", (code, reason) => {
|
|
435
|
-
if (stopped) return;
|
|
436
|
-
if (!opened) { fail(new Error("upstream closed before handshake completed")); return; }
|
|
437
|
-
try {
|
|
438
|
-
if (!clientClosed) {
|
|
439
|
-
selectCandidate();
|
|
440
|
-
upstreamClosure = { code, reason: reason.toString() };
|
|
441
|
-
record.close = { ...timing(), initiator: "server", code, reason: scrub(reason.toString()) };
|
|
359
|
+
return forwardWebSocket({ ...args, observe: {
|
|
360
|
+
open(protocol) {
|
|
361
|
+
anchor = performance.now();
|
|
362
|
+
if (protocol) record.protocol = scrub(protocol);
|
|
363
|
+
args.captured(record);
|
|
364
|
+
},
|
|
365
|
+
input(message) {
|
|
366
|
+
const input = typeof message === "string" ? message : Uint8Array.from(message);
|
|
367
|
+
if (args.match.candidates) {
|
|
368
|
+
const at = performance.now();
|
|
369
|
+
for (const key of args.match.candidates(input)) candidate = { key, count: occurrence(occurrences, key), at, index: ++candidateIndex };
|
|
370
|
+
} else {
|
|
371
|
+
const signature = args.match(input);
|
|
372
|
+
if (signature !== null) { record.inputs.push(signature); anchor = performance.now(); }
|
|
442
373
|
}
|
|
443
|
-
if (socket) { closeSocket(socket, code, reason.toString()); finish(); }
|
|
444
|
-
// If the upstream closes before downstream open, open() flushes the queue.
|
|
445
|
-
} catch (error) { fail(error); }
|
|
446
|
-
});
|
|
447
|
-
const opening = new Promise<void>((resolve, reject) => {
|
|
448
|
-
rejectOpen = reject;
|
|
449
|
-
upstream.once("open", () => {
|
|
450
|
-
try {
|
|
451
|
-
opened = true;
|
|
452
|
-
anchor = performance.now();
|
|
453
|
-
if (upstream.protocol) record.protocol = scrub(upstream.protocol);
|
|
454
|
-
args.captured(record);
|
|
455
|
-
openingTimer = setTimeout(() => fail(new Error("downstream did not open")), 10_000);
|
|
456
|
-
resolve();
|
|
457
|
-
} catch (error) { fail(error); }
|
|
458
|
-
});
|
|
459
|
-
});
|
|
460
|
-
const handlers: FakeWebSocketHandlers = {
|
|
461
|
-
open(ws) {
|
|
462
|
-
socket = ws;
|
|
463
|
-
clearTimeout(openingTimer);
|
|
464
|
-
if (stopped) { ws.close(1011, "WebSocket recording failed"); return; }
|
|
465
|
-
try {
|
|
466
|
-
for (const message of queue) ws.send(message);
|
|
467
|
-
queue = []; queuedBytes = 0;
|
|
468
|
-
if (upstreamClosure) { closeSocket(ws, upstreamClosure.code, upstreamClosure.reason); finish(); }
|
|
469
|
-
} catch (error) { fail(error); }
|
|
470
374
|
},
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
const at = performance.now();
|
|
477
|
-
for (const key of args.match.candidates(input)) candidate = { key, count: occurrence(occurrences, key), at, index: ++candidateIndex };
|
|
478
|
-
} else {
|
|
479
|
-
const signature = args.match(input);
|
|
480
|
-
if (signature !== null) { record.inputs.push(signature); anchor = performance.now(); }
|
|
481
|
-
}
|
|
482
|
-
const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
|
|
483
|
-
if (size + upstream.bufferedAmount > MAX_BUFFER) throw new Error("WebSocket upstream buffer exceeded");
|
|
484
|
-
if (upstream.readyState !== WebSocket.OPEN) throw new Error("upstream is not open");
|
|
485
|
-
upstream.send(message);
|
|
486
|
-
} catch (error) { fail(error); throw error; }
|
|
375
|
+
output(message) {
|
|
376
|
+
const binary = typeof message !== "string";
|
|
377
|
+
const redacted = binary ? redactBinary(Buffer.from(message), args.redact, args.secrets) : scrub(message);
|
|
378
|
+
if (args.match.output?.(binary ? new Uint8Array(Buffer.from(redacted, "base64")) : redacted)) selectCandidate();
|
|
379
|
+
record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
|
|
487
380
|
},
|
|
488
|
-
close(
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
clientClosed = true;
|
|
492
|
-
selectCandidate();
|
|
493
|
-
record.close = { ...timing(), initiator: "client", code, reason: scrub(reason) };
|
|
494
|
-
closeSocket(upstream, code, reason);
|
|
495
|
-
openingTimer = setTimeout(() => finish(), 1_000);
|
|
496
|
-
} catch (error) { fail(error); }
|
|
381
|
+
close(code, reason, initiator) {
|
|
382
|
+
selectCandidate();
|
|
383
|
+
record.close = { ...timing(), initiator, code, reason: scrub(reason) };
|
|
497
384
|
},
|
|
498
|
-
};
|
|
499
|
-
detach = lifetime.onEnd(event => {
|
|
500
|
-
if (event.kind === "closed" && socket) handlers.close!(socket, event.code, event.reason);
|
|
501
|
-
else session.cancel();
|
|
502
|
-
});
|
|
503
|
-
await opening;
|
|
504
|
-
handlers.protocol = upstream.protocol || undefined;
|
|
505
|
-
return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
|
|
385
|
+
} });
|
|
506
386
|
}
|
|
507
387
|
|
|
508
388
|
function redactBinary(bytes: Buffer, redact: (text: string) => string, secrets: { ref: string; value: string }[]): string {
|