@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 +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 +4 -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 +3 -1
- package/src/fake-upstream-websocket.ts +157 -0
- package/src/fake-upstream.ts +465 -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/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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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 (
|
|
448
|
-
record.protocol = scrub(
|
|
336
|
+
if (protocol)
|
|
337
|
+
record.protocol = scrub(protocol);
|
|
449
338
|
args.captured(record);
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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
|