@specific.dev/spectest 0.115.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.115.1",
3
+ "version": "0.116.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- // 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;
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
- const finish = (error?: Error) => {
396
- if (stopped) return;
397
- stopped = true;
398
- clearTimeout(openingTimer);
399
- detach();
400
- queue = [];
401
- upstream.terminate();
402
- lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
403
- args.finished(session, error);
404
- };
405
- const fail = (error: unknown) => {
406
- if (stopped) return;
407
- const failure = new Error(`${args.label}: ${(error as Error).message}`);
408
- rejectOpen(failure);
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
- message(_ws, message) {
472
- if (stopped) return;
473
- try {
474
- const input = typeof message === "string" ? message : Uint8Array.from(message);
475
- if (args.match.candidates) {
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(_ws, code, reason) {
489
- if (stopped || clientClosed) return;
490
- try {
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 {