@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/README.md CHANGED
@@ -49,6 +49,50 @@ shown in a collapsed **Instructions** section in the dashboard and PR
49
49
  comment. Blank instructions are omitted.
50
50
 
51
51
 
52
+ ## Live APIs in interactive environments
53
+
54
+ Add `live` to a scripted fake to forward its entire HTTP and WebSocket surface
55
+ in interactive environments, without recording requests or responses:
56
+
57
+ ```ts
58
+ const soniox = defineFake({
59
+ name: "soniox",
60
+ hostnames: ["api.eu.soniox.com", "stt-rt.eu.soniox.com"],
61
+ state: createState,
62
+ helpers: createHelpers,
63
+ handler: handleHttp,
64
+ websocket: handleWebSocket,
65
+ live: {
66
+ inject: [{
67
+ headers: { authorization: "Bearer {{secret:SONIOX_API_KEY}}" },
68
+ }],
69
+ },
70
+ });
71
+ ```
72
+
73
+ Tests and shared baseline setup always use the scripted handlers, even when
74
+ credentials are available. Interactive forwarding starts after the shared
75
+ baseline and before interactive seeding. Every declared hostname forwards to
76
+ that same real host over HTTPS/WSS on port 443. Scripted handlers are bypassed;
77
+ helpers remain available but do not observe or control live provider traffic.
78
+
79
+ `live: {}` forwards without credential overrides. `live.inject` uses the same
80
+ first-matching header rules as `replayFake` for HTTP requests and WebSocket
81
+ handshakes; matches select credentials, not which traffic is forwarded.
82
+ `live.sign` supports the same AWS SigV4 signing for HTTP (not WebSockets).
83
+ Secret references are resolved from the project's Secrets store. Missing
84
+ credentials and upstream failures surface as errors without scripted fallback.
85
+
86
+ For Soniox, use header authentication and omit `api_key` from the initial
87
+ WebSocket message. The application can use a dummy key; Spectest overwrites
88
+ its outbound Authorization header with the real one. Provider callbacks and
89
+ provider-fetched audio URLs must separately be reachable in the environment.
90
+
91
+ Unlike `replayFake`, live fakes have no cassette state or recording helpers.
92
+ HTTP payloads may be buffered during forwarding and WebSocket opening buffers
93
+ are bounded; neither is retained as a recording. Application-level storage and
94
+ logs remain under application control.
95
+
52
96
  ## Microphone fixtures and WebSocket fakes (0.103.0)
53
97
 
54
98
  Supply a 16-bit PCM mono/stereo WAV when creating a browser session:
@@ -2,6 +2,8 @@ import type { FakeWebSocketHandlers } from "../fake-websocket.js";
2
2
  import type { CassetteRequest } from "./replayFake.js";
3
3
  import { FakeWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
4
4
  import { type MatchKey } from "./replay-key.js";
5
+ import { type WebSocketSession } from "../fake-upstream-websocket.js";
6
+ export type { WebSocketSession } from "../fake-upstream-websocket.js";
5
7
  export type ReplayWebSocketMessage = string | Uint8Array;
6
8
  export type WebSocketKey<S = unknown> = ((message: ReplayWebSocketMessage) => MatchKey) | {
7
9
  initial: S;
@@ -63,9 +65,6 @@ export declare function validateWebSocketKey<S>(key: WebSocketKey<S> | undefined
63
65
  /** Clone once per connection; never retain incoming payloads. */
64
66
  export declare function websocketMatcher<S>(key?: WebSocketKey<S>): WebSocketMatcher;
65
67
  export declare function validateWebSocketRecording(record: CassetteWebSocket): void;
66
- export interface WebSocketSession {
67
- cancel(): void;
68
- }
69
68
  export declare function replayWebSocket(args: {
70
69
  recording: CassetteWebSocket;
71
70
  match: WebSocketMatcher;
@@ -1,12 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
- import { createRequire } from "node:module";
3
- import { dirname, join } from "node:path";
4
2
  import { FakeWebSocketLifetime, bindWebSocketLifetime } from "../harness/fake-websocket-lifetime.js";
5
3
  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;
4
+ import { MAX_BUFFER, closeSocket, forwardWebSocket } from "../fake-upstream-websocket.js";
10
5
  const MAX_PENDING = 1024;
11
6
  const MAX_CANDIDATE_KEYS = 65536;
12
7
  function occurrence(counts, key) {
@@ -144,15 +139,6 @@ export function validateWebSocketRecording(record) {
144
139
  function validCloseCode(code) {
145
140
  return Number.isInteger(code) && ((code >= 1000 && code <= 1014 && ![1004, 1005, 1006].includes(code)) || (code >= 3000 && code <= 4999) || code === 1005 || code === 1006);
146
141
  }
147
- function closeSocket(socket, code, reason) {
148
- // Reserved status codes describe transport closure and cannot go on the wire.
149
- if (code === 1006 && socket.terminate)
150
- socket.terminate();
151
- else if (code === 1005 || code === 1006)
152
- socket.close();
153
- else
154
- socket.close(code, reason);
155
- }
156
142
  export function replayWebSocket(args) {
157
143
  const { recording: record, label } = args;
158
144
  if ((record.inputMode === "anchors") !== !!args.match.candidates)
@@ -321,27 +307,12 @@ export function replayWebSocket(args) {
321
307
  }
322
308
  /** Open upstream before accepting downstream, retaining only a bounded opening queue. */
323
309
  export async function recordWebSocket(args) {
324
- const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
325
- if (lifetime.ended)
326
- throw new Error("WebSocket handshake aborted");
327
- let detach = () => { };
328
- const upstream = new WebSocket(args.url, args.protocols, {
329
- headers: args.headers, maxPayload: MAX_BUFFER,
330
- handshakeTimeout: 10_000,
331
- lookup: ((_hostname, options, callback) => {
332
- if (options.all)
333
- callback(null, [{ address: args.ip, family: 4 }]);
334
- else
335
- callback(null, args.ip, 4);
336
- }),
337
- });
338
310
  const record = { request: args.request, inputs: [], messages: [],
339
311
  ...(args.match.candidates ? { inputMode: "anchors", inputOccurrences: [] } : {}),
340
312
  };
341
313
  const occurrences = new Map();
342
314
  let candidate;
343
315
  let candidateIndex = 0, selectedIndex = 0;
344
- let socket;
345
316
  let anchor = performance.now();
346
317
  const selectCandidate = () => {
347
318
  if (!candidate || candidate.index === selectedIndex)
@@ -351,14 +322,6 @@ export async function recordWebSocket(args) {
351
322
  anchor = candidate.at;
352
323
  selectedIndex = candidate.index;
353
324
  };
354
- let stopped = false;
355
- let clientClosed = false;
356
- let upstreamClosure;
357
- let queue = [];
358
- let queuedBytes = 0;
359
- let openingTimer;
360
- let opened = false;
361
- let rejectOpen = () => { };
362
325
  const timing = () => ({ afterInput: record.inputs.length, delayMs: Math.max(0, performance.now() - anchor) });
363
326
  const scrub = (value) => {
364
327
  const result = args.redact(value);
@@ -367,120 +330,14 @@ export async function recordWebSocket(args) {
367
330
  throw new Error(`secret ${secret.ref} survived WebSocket redaction`);
368
331
  return result;
369
332
  };
370
- const finish = (error) => {
371
- if (stopped)
372
- return;
373
- stopped = true;
374
- clearTimeout(openingTimer);
375
- detach();
376
- queue = [];
377
- upstream.terminate();
378
- lifetime.end(error ? { kind: "failed", error } : { kind: "aborted" });
379
- args.finished(session, error);
380
- };
381
- const fail = (error) => {
382
- if (stopped)
383
- return;
384
- const failure = new Error(`${args.label}: ${error.message}`);
385
- rejectOpen(failure);
386
- finish(failure);
387
- socket?.close(1011, "WebSocket recording failed");
388
- };
389
- const session = {
390
- cancel() { rejectOpen(new Error("WebSocket handshake aborted")); finish(); socket?.close(1001, "recording cleanup"); },
391
- };
392
- upstream.on("error", fail);
393
- upstream.on("message", (data, binary) => {
394
- if (stopped || clientClosed)
395
- return;
396
- try {
397
- const bytes = Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
398
- const message = binary ? new Uint8Array(bytes) : bytes.toString();
399
- // Redact bytes too: base64 encoding must not hide a brokered secret.
400
- const redacted = binary ? redactBinary(bytes, args.redact, args.secrets) : scrub(bytes.toString());
401
- // Apply the same redacted output reducer that replay will see.
402
- if (args.match.output?.(binary ? new Uint8Array(Buffer.from(redacted, "base64")) : redacted))
403
- selectCandidate();
404
- record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
405
- if (socket)
406
- socket.send(message);
407
- else {
408
- queuedBytes += bytes.length;
409
- if (queue.length >= MAX_PENDING || queuedBytes > MAX_BUFFER)
410
- throw new Error("WebSocket opening buffer exceeded");
411
- queue.push(message);
412
- }
413
- }
414
- catch (error) {
415
- fail(error);
416
- }
417
- });
418
- upstream.on("close", (code, reason) => {
419
- if (stopped)
420
- return;
421
- if (!opened) {
422
- fail(new Error("upstream closed before handshake completed"));
423
- return;
424
- }
425
- try {
426
- if (!clientClosed) {
427
- selectCandidate();
428
- upstreamClosure = { code, reason: reason.toString() };
429
- record.close = { ...timing(), initiator: "server", code, reason: scrub(reason.toString()) };
430
- }
431
- if (socket) {
432
- closeSocket(socket, code, reason.toString());
433
- finish();
434
- }
435
- // If the upstream closes before downstream open, open() flushes the queue.
436
- }
437
- catch (error) {
438
- fail(error);
439
- }
440
- });
441
- const opening = new Promise((resolve, reject) => {
442
- rejectOpen = reject;
443
- upstream.once("open", () => {
444
- try {
445
- opened = true;
333
+ return forwardWebSocket({ ...args, observe: {
334
+ open(protocol) {
446
335
  anchor = performance.now();
447
- if (upstream.protocol)
448
- record.protocol = scrub(upstream.protocol);
336
+ if (protocol)
337
+ record.protocol = scrub(protocol);
449
338
  args.captured(record);
450
- openingTimer = setTimeout(() => fail(new Error("downstream did not open")), 10_000);
451
- resolve();
452
- }
453
- catch (error) {
454
- fail(error);
455
- }
456
- });
457
- });
458
- const handlers = {
459
- open(ws) {
460
- socket = ws;
461
- clearTimeout(openingTimer);
462
- if (stopped) {
463
- ws.close(1011, "WebSocket recording failed");
464
- return;
465
- }
466
- try {
467
- for (const message of queue)
468
- ws.send(message);
469
- queue = [];
470
- queuedBytes = 0;
471
- if (upstreamClosure) {
472
- closeSocket(ws, upstreamClosure.code, upstreamClosure.reason);
473
- finish();
474
- }
475
- }
476
- catch (error) {
477
- fail(error);
478
- }
479
- },
480
- message(_ws, message) {
481
- if (stopped)
482
- return;
483
- try {
339
+ },
340
+ input(message) {
484
341
  const input = typeof message === "string" ? message : Uint8Array.from(message);
485
342
  if (args.match.candidates) {
486
343
  const at = performance.now();
@@ -494,42 +351,19 @@ export async function recordWebSocket(args) {
494
351
  anchor = performance.now();
495
352
  }
496
353
  }
497
- const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
498
- if (size + upstream.bufferedAmount > MAX_BUFFER)
499
- throw new Error("WebSocket upstream buffer exceeded");
500
- if (upstream.readyState !== WebSocket.OPEN)
501
- throw new Error("upstream is not open");
502
- upstream.send(message);
503
- }
504
- catch (error) {
505
- fail(error);
506
- throw error;
507
- }
508
- },
509
- close(_ws, code, reason) {
510
- if (stopped || clientClosed)
511
- return;
512
- try {
513
- clientClosed = true;
354
+ },
355
+ output(message) {
356
+ const binary = typeof message !== "string";
357
+ const redacted = binary ? redactBinary(Buffer.from(message), args.redact, args.secrets) : scrub(message);
358
+ if (args.match.output?.(binary ? new Uint8Array(Buffer.from(redacted, "base64")) : redacted))
359
+ selectCandidate();
360
+ record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
361
+ },
362
+ close(code, reason, initiator) {
514
363
  selectCandidate();
515
- record.close = { ...timing(), initiator: "client", code, reason: scrub(reason) };
516
- closeSocket(upstream, code, reason);
517
- openingTimer = setTimeout(() => finish(), 1_000);
518
- }
519
- catch (error) {
520
- fail(error);
521
- }
522
- },
523
- };
524
- detach = lifetime.onEnd(event => {
525
- if (event.kind === "closed" && socket)
526
- handlers.close(socket, event.code, event.reason);
527
- else
528
- session.cancel();
529
- });
530
- await opening;
531
- handlers.protocol = upstream.protocol || undefined;
532
- return { session, handlers: bindWebSocketLifetime(handlers, lifetime) };
364
+ record.close = { ...timing(), initiator, code, reason: scrub(reason) };
365
+ },
366
+ } });
533
367
  }
534
368
  function redactBinary(bytes, redact, secrets) {
535
369
  // Preserve arbitrary bytes; textual patterns can only safely apply to valid UTF-8.
@@ -3,59 +3,8 @@ import { type WebSocketKey, type CassetteWebSocket, type WebSocketSession } from
3
3
  export type { WebSocketKey, ReplayWebSocketMessage, CassetteWebSocket, CassetteWebSocketMessage, WebSocketTiming } from "./replay-websocket.js";
4
4
  import { type ReplayKey } from "./replay-key.js";
5
5
  export type { JsonValue, MatchKey, ReplayRequest, ReplayKey } from "./replay-key.js";
6
- /** Match one request dimension. A bare string is an exact match; the
7
- * object forms cover prefix and regex matching. */
8
- export type StringMatch = string | {
9
- exact: string;
10
- } | {
11
- startsWith: string;
12
- } | {
13
- regex: string;
14
- };
15
- /** Request matchers for a credential-injection rule. A rule applies only
16
- * when EVERY specified dimension matches; omit `match` to always apply. */
17
- export interface InjectMatch {
18
- /** Match the request path (no query). */
19
- path?: StringMatch;
20
- /** HTTP method(s) — any one matching satisfies it. */
21
- method?: string | string[];
22
- /** Query-param matchers (ANDed); each key's value(s) must match. */
23
- query?: Record<string, StringMatch>;
24
- /** Header matchers (ANDed; header names case-insensitive). */
25
- headers?: Record<string, StringMatch>;
26
- }
27
- /** One credential-brokering rule. When it matches a request, these headers
28
- * are SET on the egress forward — overwriting whatever the app sent (so app
29
- * code can't smuggle a different value past the broker). Header VALUES may
30
- * embed `{{secret:REF}}` tokens; each `REF` is resolved server-side from the
31
- * project's Secrets store and supplied for the environment lifetime. Values
32
- * never enter project files, the warm-cache hash, or a cassette (redaction
33
- * fails closed). */
34
- export interface InjectRule {
35
- match?: InjectMatch;
36
- headers: Record<string, string>;
37
- }
38
- /** SigV4 request-signing broker. Unlike `inject` (which SETs a static header
39
- * value), this RE-SIGNS the outbound request: the app signs with throwaway
40
- * dummy credentials, we strip that signature and re-sign the exact outbound
41
- * request with the real credentials — the only way to broker AWS auth, whose
42
- * signature is a keyed HMAC over the whole request, not a static token.
43
- *
44
- * The three fields are **secret refs, resolved directly** from the project's
45
- * Secrets store (NOT `{{secret:}}` templates), with the same lifetime and
46
- * fail-closed redaction as `inject`. Region + service are inferred from the
47
- * incoming request (its credential scope, else the host); the app never
48
- * configures them. */
49
- export interface AwsSigV4Sign {
50
- type: "awsSigv4";
51
- /** Secret ref for the AWS access key id. */
52
- accessKeyId: string;
53
- /** Secret ref for the AWS secret access key. */
54
- secretAccessKey: string;
55
- /** Optional secret ref for a session token (temporary credentials). */
56
- sessionToken?: string;
57
- }
58
- export type SignConfig = AwsSigV4Sign;
6
+ import { type InjectRule, type SignConfig } from "../fake-upstream.js";
7
+ export type { StringMatch, InjectMatch, InjectRule, AwsSigV4Sign, SignConfig } from "../fake-upstream.js";
59
8
  /** A normal fake context with explicit delegation to recording/replay. */
60
9
  export interface ReplayFakeContext extends FakeContext {
61
10
  /** Record against the real upstream in record mode, or consume a matching