@specific.dev/spectest 0.110.0 → 0.112.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/phone.js ADDED
@@ -0,0 +1,343 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import { fileURLToPath } from "node:url";
4
+ import { mkdtemp, open, rm, stat } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { randomUUID } from "node:crypto";
8
+ import { setTimeout as delay } from "node:timers/promises";
9
+ import { captureRecording, recordStep, reserveEvent } from "./recorder.js";
10
+ import { deepUnwrap } from "./inspect.js";
11
+ const MAX_RECORDING_BYTES = 96 * 1024 * 1024;
12
+ const parked = new Map();
13
+ export async function phoneRecordingChunk(id, offset, limit) {
14
+ const entry = parked.get(id);
15
+ if (!entry)
16
+ throw new Error("Unknown phone recording");
17
+ const start = Number(offset ?? 0), count = Number(limit ?? 6 * 1024 * 1024);
18
+ if (!Number.isSafeInteger(start) || start < 0 || start > entry.bytes || !Number.isSafeInteger(count) || count < 1 || count > 6 * 1024 * 1024)
19
+ throw new Error("Invalid phone recording range");
20
+ const file = await open(entry.path, "r");
21
+ try {
22
+ const buffer = Buffer.alloc(Math.min(count, entry.bytes - start));
23
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
24
+ return { b64: buffer.subarray(0, bytesRead).toString("base64"), offset: start, total: entry.bytes };
25
+ }
26
+ finally {
27
+ await file.close();
28
+ }
29
+ }
30
+ export async function releasePhoneRecording(id) {
31
+ const entry = parked.get(id);
32
+ if (entry) {
33
+ parked.delete(id);
34
+ await rm(entry.path, { force: true });
35
+ if (![...parked.values()].some(p => p.directory === entry.directory))
36
+ await rm(entry.directory, { recursive: true, force: true });
37
+ }
38
+ return { released: true };
39
+ }
40
+ class PhoneSession {
41
+ options;
42
+ caseStart;
43
+ reserveCall;
44
+ pending = new Map();
45
+ nextId = 0;
46
+ closed = false;
47
+ failure;
48
+ cancellation = new AbortController();
49
+ tail = Promise.resolve();
50
+ child;
51
+ exited;
52
+ closing;
53
+ directory;
54
+ activeCall;
55
+ calls = new Map();
56
+ constructor(options, caseStart, reserveCall) {
57
+ this.options = options;
58
+ this.caseStart = caseStart;
59
+ this.reserveCall = reserveCall;
60
+ if (!/^[a-zA-Z0-9.-]+(?::\d{1,5})?$/.test(options.endpoint))
61
+ throw new Error("phone.endpoint must be a hostname or IPv4 address with optional port");
62
+ if (!/^sip:[^\s<>]+$/.test(options.from))
63
+ throw new Error("phone.from must be a sip: URI");
64
+ if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0))
65
+ throw new Error("phone.timeoutMs must be positive");
66
+ this.child = spawn(process.env.SPECTEST_PHONE_PYTHON ?? "/opt/spectest/phone/bin/python", [fileURLToPath(new URL("./harness/phone.py", import.meta.url))], { stdio: ["pipe", "pipe", "pipe"] });
67
+ this.exited = new Promise(resolve => { this.child.once("close", () => resolve()); });
68
+ // Native logs can contain authentication headers. Never record them.
69
+ this.child.stderr.resume();
70
+ createInterface({ input: this.child.stdout }).on("line", line => {
71
+ let reply;
72
+ try {
73
+ reply = JSON.parse(line);
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ if (reply.event === "recording" && reply.sessionId) {
79
+ const call = this.calls.get(reply.sessionId);
80
+ if (call)
81
+ call.recording = { id: reply.sessionId, startedAtMs: reply.startedAtMs - this.caseStart, duration: reply.duration, bytes: reply.bytes, peaks: reply.peaks, callerPeaks: reply.callerPeaks, recipientPeaks: reply.recipientPeaks, contentType: reply.contentType, encodingFinalizeMs: reply.encodingFinalizeMs, error: reply.error };
82
+ return;
83
+ }
84
+ const waiter = this.pending.get(reply.id);
85
+ if (!waiter)
86
+ return;
87
+ if (typeof reply.offset === "number" && Number.isFinite(reply.offset) && reply.offset >= 0) {
88
+ waiter.position?.(reply.event ?? "operation_ended", reply.offset);
89
+ }
90
+ if (reply.event === "speech_started" || reply.event === "operation_started") {
91
+ return;
92
+ }
93
+ clearTimeout(waiter.timer);
94
+ this.pending.delete(reply.id);
95
+ if (reply.error)
96
+ waiter.reject(new Error(reply.error));
97
+ else
98
+ waiter.resolve(reply);
99
+ });
100
+ this.child.on("error", () => this.fail(new Error("Phone runtime unavailable. Rebuild the golden image with phone support.")));
101
+ this.child.on("exit", () => this.fail(new Error("Phone runtime exited")));
102
+ this.child.stdin.on("error", () => this.fail(new Error("Phone runtime input closed")));
103
+ }
104
+ fail(error) {
105
+ this.closed = true;
106
+ this.failure ??= error;
107
+ this.cancellation.abort(this.failure);
108
+ for (const p of this.pending.values()) {
109
+ clearTimeout(p.timer);
110
+ p.reject(error);
111
+ }
112
+ this.pending.clear();
113
+ }
114
+ request(action, data = {}, timeout = this.options.timeoutMs ?? 30_000, position) {
115
+ if (this.closed)
116
+ return Promise.reject(new Error("Phone is closed"));
117
+ const id = ++this.nextId;
118
+ return new Promise((resolve, reject) => {
119
+ const timer = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
120
+ this.pending.set(id, { resolve, reject, timer, position });
121
+ this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
122
+ });
123
+ }
124
+ async initialize() {
125
+ this.directory = await mkdtemp(join(tmpdir(), "spectest-phone-"));
126
+ if (this.closed)
127
+ throw this.failure ?? new Error("Phone is closed");
128
+ await this.request("open", { ...this.options, directory: this.directory }, 60_000);
129
+ return this;
130
+ }
131
+ async waitForResponse(options) {
132
+ const timeout = 30_000;
133
+ const deadline = performance.now() + timeout;
134
+ const controller = new AbortController();
135
+ const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
136
+ this.cancellation.signal.addEventListener("abort", cancel, { once: true });
137
+ if (this.closed)
138
+ cancel();
139
+ const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
140
+ const aborted = new Promise((_, reject) => {
141
+ const rejectAbort = () => reject(controller.signal.reason);
142
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
143
+ if (controller.signal.aborted)
144
+ rejectAbort();
145
+ });
146
+ // Probes are observations: suppress their internal events without affecting
147
+ // concurrent phones, and discard late results after timeout/case cleanup.
148
+ const branch = captureRecording();
149
+ try {
150
+ return await Promise.race([aborted, branch.run(async () => {
151
+ let response;
152
+ if (options.waitFor) {
153
+ for (;;) {
154
+ controller.signal.throwIfAborted();
155
+ const ready = deepUnwrap(await options.waitFor());
156
+ controller.signal.throwIfAborted();
157
+ if (ready) {
158
+ if (typeof ready === "string")
159
+ response = ready;
160
+ break;
161
+ }
162
+ await delay(100, undefined, { signal: controller.signal });
163
+ }
164
+ }
165
+ controller.signal.throwIfAborted();
166
+ const remaining = Math.max(1, deadline - performance.now());
167
+ const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
168
+ return { ...reply, response };
169
+ })]);
170
+ }
171
+ finally {
172
+ clearTimeout(timer);
173
+ this.cancellation.signal.removeEventListener("abort", cancel);
174
+ controller.abort();
175
+ branch.finish(false);
176
+ }
177
+ }
178
+ operation(action, title, data = {}, timeout, options) {
179
+ if (this.closed)
180
+ return Promise.reject(new Error("Phone is closed"));
181
+ const work = this.tail.then(async () => {
182
+ if (this.closed)
183
+ throw new Error("Phone is closed");
184
+ const reservation = reserveEvent(), start = Date.now();
185
+ if (action === "call") {
186
+ if (this.activeCall && !this.calls.get(this.activeCall)?.recording)
187
+ throw new Error("Already in a call; hang up before dialing again");
188
+ this.reserveCall();
189
+ this.activeCall = randomUUID();
190
+ this.calls.set(this.activeCall, { started: start, steps: [] });
191
+ data = { ...data, sessionId: this.activeCall };
192
+ }
193
+ const text = action === "say" ? data.text : "";
194
+ const blocks = [];
195
+ let step;
196
+ if (this.activeCall) {
197
+ const block = { type: "audio", sessionId: this.activeCall, text, start: 0, end: 0, duration: 0, peaks: [] };
198
+ if (action === "call" && options?.waitFor)
199
+ block.waitedForSpeech = true;
200
+ step = { block };
201
+ this.calls.get(this.activeCall).steps.push(step);
202
+ // Retain the boundary for recording finalization, without rendering a
203
+ // player on the hangup step.
204
+ if (action !== "hangup")
205
+ blocks.push(block);
206
+ }
207
+ else if (text)
208
+ blocks.push({ type: "text", text });
209
+ try {
210
+ let reply = await this.request(action, data, timeout, (event, offset) => {
211
+ if (!step)
212
+ return;
213
+ if (event === "speech_started")
214
+ step.playbackStart = offset;
215
+ if (event === "operation_started") {
216
+ step.playbackStart = offset;
217
+ const steps = this.calls.get(this.activeCall).steps;
218
+ const previous = steps.at(-2);
219
+ // Dialing owns the greeting/idle audio up to the next operation.
220
+ if (previous && previous.playbackEnd === undefined)
221
+ previous.playbackEnd = offset;
222
+ }
223
+ if (event === "operation_ended")
224
+ step.playbackEnd = offset;
225
+ });
226
+ if (options?.waitFor) {
227
+ const waited = await this.waitForResponse(options);
228
+ reply = waited;
229
+ if (step && waited.response !== undefined)
230
+ step.block.response = waited.response;
231
+ }
232
+ if ((action === "say" || options?.waitFor) && step) {
233
+ step.playbackEnd = reply.offset;
234
+ }
235
+ recordStep({ kind: "phone", title, blocks, status: "passed", durationMs: Date.now() - start }, reservation);
236
+ if (action === "hangup")
237
+ this.activeCall = undefined;
238
+ }
239
+ catch (error) {
240
+ if ((action === "say" || action === "call") && step) {
241
+ // A predicate can fail independently of the native transport. Sample
242
+ // its current position too; wall time is not an audio-file offset.
243
+ const position = this.closed ? undefined : await this.request("position").catch(() => undefined);
244
+ step.playbackEnd = this.closed ? undefined : position?.offset ?? step.playbackEnd;
245
+ }
246
+ recordStep({ kind: "phone", title, blocks, status: "failed", error: String(error), durationMs: Date.now() - start }, reservation);
247
+ throw error;
248
+ }
249
+ });
250
+ this.tail = work.catch(() => { });
251
+ return work;
252
+ }
253
+ call(destination, options = {}) {
254
+ if (!destination || /[\s<>\r\n]/.test(destination))
255
+ return Promise.reject(new Error("Invalid phone destination"));
256
+ if (options.waitFor !== undefined && typeof options.waitFor !== "function")
257
+ return Promise.reject(new Error("phone.call waitFor must be a predicate"));
258
+ return this.operation("call", `Call ${destination}`, { destination }, undefined, options);
259
+ }
260
+ say(text, options = {}) {
261
+ if (!text.trim() || text.length > 2000)
262
+ return Promise.reject(new Error("phone.say requires 1–2,000 characters"));
263
+ if (options.speed !== undefined && (!Number.isFinite(options.speed) || options.speed < 0.7 || options.speed > 2))
264
+ return Promise.reject(new Error("Speech speed must be between 0.7 and 2"));
265
+ if (options.waitFor !== undefined && typeof options.waitFor !== "function")
266
+ return Promise.reject(new Error("phone.say waitFor must be a predicate"));
267
+ const { waitFor, ...voice } = options;
268
+ return this.operation("say", `Say ${JSON.stringify(text)}`, { ...voice, language: voice.language ?? this.options.language ?? "en", text }, 180_000, { waitFor });
269
+ }
270
+ hangup() { return this.operation("hangup", "Hang up"); }
271
+ close() { return this.closing ??= this.dispose(); }
272
+ async dispose() {
273
+ this.fail(new Error("Phone closed"));
274
+ this.child.stdin.end();
275
+ // EOF interrupts synthesis/playback, hangs up, and seals the recording.
276
+ const timer = setTimeout(() => this.child.kill("SIGKILL"), 10_000);
277
+ await this.exited;
278
+ clearTimeout(timer);
279
+ await this.tail;
280
+ }
281
+ async finish(record) {
282
+ await this.close();
283
+ const recordings = [];
284
+ for (const [id, call] of this.calls) {
285
+ const r = call.recording ?? { id, startedAtMs: call.started - this.caseStart, duration: 0, bytes: 0, contentType: "audio/webm", peaks: [], error: "Phone runtime exited before the call recording was finalized" };
286
+ const path = join(this.directory, `${id}.webm`);
287
+ if (!r.error) {
288
+ const size = (await stat(path).catch(() => null))?.size;
289
+ if (size !== r.bytes || r.bytes < 44 || r.bytes > MAX_RECORDING_BYTES)
290
+ r.error = "Call recording is missing or exceeds the size limit";
291
+ }
292
+ // Say owns transmission through its optional response wait. Unrelated
293
+ // work and later idle time stay only in the continuous call recording.
294
+ // Dial/hangup retain the surrounding call audio (including the greeting).
295
+ for (let i = 0; i < call.steps.length; i++) {
296
+ const step = call.steps[i];
297
+ const end = Math.min(r.duration, step.playbackEnd ?? call.steps[i + 1]?.playbackStart ?? r.duration);
298
+ Object.assign(step.block, { duration: r.duration, start: Math.min(end, step.playbackStart ?? 0), end });
299
+ }
300
+ if (record && !r.error)
301
+ parked.set(id, { path, directory: this.directory, bytes: r.bytes });
302
+ recordings.push(r);
303
+ }
304
+ if (this.directory && ![...parked.values()].some(p => p.directory === this.directory))
305
+ await rm(this.directory, { recursive: true, force: true });
306
+ return record ? recordings : [];
307
+ }
308
+ }
309
+ export function phoneScope(caseStart = Date.now(), { record = true } = {}) {
310
+ const sessions = new Set();
311
+ let closed = false, calls = 0;
312
+ let finished;
313
+ // A fork inherits its parent's completed files. Discard those before opening
314
+ // this case's phones; the parent VM retains its own copies for archival.
315
+ const cleanup = (async () => {
316
+ // Calls from one phone share a directory; retire files sequentially so
317
+ // only the last release removes it.
318
+ for (const id of [...parked.keys()])
319
+ await releasePhoneRecording(id);
320
+ })();
321
+ return {
322
+ async open(options) {
323
+ await cleanup;
324
+ if (closed)
325
+ throw new Error("Phone test scope has ended");
326
+ const session = new PhoneSession(options, caseStart, () => { if (++calls > 8)
327
+ throw new Error("A case can record at most eight calls"); });
328
+ sessions.add(session);
329
+ try {
330
+ return await session.initialize();
331
+ }
332
+ catch (error) {
333
+ await session.finish(false);
334
+ throw error;
335
+ }
336
+ },
337
+ async close() { closed = true; await Promise.all([...sessions].map(s => s.close())); },
338
+ finish() {
339
+ closed = true;
340
+ return finished ??= Promise.all([...sessions].map(s => s.finish(record))).then(records => records.flat());
341
+ },
342
+ };
343
+ }
@@ -617,6 +617,16 @@ export declare function recordEnv(ev: Omit<EnvEvent, "seq" | "tOffsetMs" | "kind
617
617
  * newer SDK's step degrades to the blocks the server knows.
618
618
  */
619
619
  export type StepBlock = {
620
+ type: "audio";
621
+ sessionId: string;
622
+ start: number;
623
+ end: number;
624
+ text: string;
625
+ duration: number;
626
+ peaks: number[];
627
+ waitedForSpeech?: boolean;
628
+ response?: string;
629
+ } | {
620
630
  type: "text";
621
631
  text: string;
622
632
  } | {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.110.0",
3
+ "version": "0.112.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,7 +15,36 @@ const MAX_PENDING = 1024;
15
15
  export type ReplayWebSocketMessage = string | Uint8Array;
16
16
  export type WebSocketKey<S = unknown> =
17
17
  | ((message: ReplayWebSocketMessage) => MatchKey)
18
- | { initial: S; reduce(state: S, message: ReplayWebSocketMessage): { state: S; key: MatchKey } };
18
+ | { initial: S; reduce(state: S, message: ReplayWebSocketMessage): { state: S; key: MatchKey } }
19
+ | {
20
+ initial: S;
21
+ /** Candidate keys, not mandatory inputs. Emit multiple keys to normalize stream chunking. */
22
+ input(state: S, message: ReplayWebSocketMessage): { state: S } & (
23
+ { key: MatchKey; keys?: never } | { keys: MatchKey[]; key?: never }
24
+ );
25
+ /** Runs on real output during recording and saved output during replay.
26
+ * Select the latest candidate as a timing anchor during recording only. */
27
+ output(state: S, message: ReplayWebSocketMessage): { state: S; anchorPreviousInput: boolean };
28
+ };
29
+
30
+ export type WebSocketMatcher = ((message: ReplayWebSocketMessage) => string | null) & {
31
+ candidates?: (message: ReplayWebSocketMessage) => string[];
32
+ output?: (message: ReplayWebSocketMessage) => boolean;
33
+ };
34
+ const MAX_CANDIDATE_KEYS = 65536;
35
+ function occurrence(counts: Map<string, number>, key: string): number {
36
+ if (!counts.has(key) && counts.size >= MAX_CANDIDATE_KEYS) throw new Error("WebSocket candidate key limit exceeded");
37
+ const count = (counts.get(key) ?? 0) + 1;
38
+ counts.set(key, count);
39
+ return count;
40
+ }
41
+ function synchronous(value: unknown, label: string): asserts value is Record<string, unknown> {
42
+ if (value && typeof value === "object" && "then" in value) {
43
+ if (value instanceof Promise) void value.catch(() => {});
44
+ throw new Error(`websocketKey ${label} must be synchronous`);
45
+ }
46
+ if (!value || typeof value !== "object" || !("state" in value)) throw new Error(`websocketKey ${label} must return state`);
47
+ }
19
48
 
20
49
  export interface WebSocketTiming {
21
50
  /** Number of emitted input keys required. Zero means connection opening. */
@@ -32,6 +61,10 @@ export interface CassetteWebSocket {
32
61
  request: CassetteRequest;
33
62
  protocol?: string;
34
63
  inputs: string[];
64
+ /** Only output-selected candidates are required in this mode. */
65
+ inputMode?: "anchors";
66
+ /** One-based occurrence of each selected hash in the full candidate stream. */
67
+ inputOccurrences?: number[];
35
68
  messages: CassetteWebSocketMessage[];
36
69
  /** Absent while recording a connection that is still open. */
37
70
  close?: WebSocketTiming & { initiator: "client" | "server"; code: number; reason: string };
@@ -39,15 +72,39 @@ export interface CassetteWebSocket {
39
72
 
40
73
  export function validateWebSocketKey<S>(key: WebSocketKey<S> | undefined): void {
41
74
  if (key === undefined || typeof key === "function") return;
42
- if (!key || typeof key !== "object" || !("initial" in key) || typeof key.reduce !== "function") {
43
- throw new Error("websocketKey must be a synchronous function or { initial, reduce }");
75
+ if (!key || typeof key !== "object" || !("initial" in key)
76
+ || !("reduce" in key && typeof key.reduce === "function" && !("input" in key) && !("output" in key)
77
+ || "input" in key && typeof key.input === "function" && "output" in key && typeof key.output === "function" && !("reduce" in key))) {
78
+ throw new Error("websocketKey must be a synchronous function, { initial, reduce }, or { initial, input, output }");
44
79
  }
45
80
  structuredClone(key.initial);
46
81
  }
47
82
 
48
83
  /** Clone once per connection; never retain incoming payloads. */
49
- export function websocketMatcher<S>(key?: WebSocketKey<S>): (message: ReplayWebSocketMessage) => string | null {
84
+ export function websocketMatcher<S>(key?: WebSocketKey<S>): WebSocketMatcher {
50
85
  let state = key && typeof key !== "function" ? structuredClone(key.initial) : undefined;
86
+ if (key && typeof key === "object" && "input" in key) {
87
+ const input = (message: ReplayWebSocketMessage) => {
88
+ const next = key.input(state as S, message);
89
+ synchronous(next, "input");
90
+ if (("key" in next) === ("keys" in next) || "keys" in next && !Array.isArray(next.keys)) throw new Error("websocketKey input must return { state, key } or { state, keys }");
91
+ const values = "keys" in next ? next.keys! : [next.key];
92
+ if (values.length > MAX_PENDING) throw new Error("WebSocket candidate batch limit exceeded");
93
+ const hashes = values.filter(value => value !== null).map(hashKey);
94
+ state = next.state;
95
+ return hashes;
96
+ };
97
+ return Object.assign((message: ReplayWebSocketMessage) => input(message).at(-1) ?? null, {
98
+ candidates: input,
99
+ output(message: ReplayWebSocketMessage) {
100
+ const next = key.output(state as S, message);
101
+ synchronous(next, "output");
102
+ if (typeof next.anchorPreviousInput !== "boolean") throw new Error("websocketKey output must return { state, anchorPreviousInput }");
103
+ state = next.state;
104
+ return next.anchorPreviousInput;
105
+ },
106
+ });
107
+ }
51
108
  return message => {
52
109
  if (!key) return createHash("sha256").update(typeof message === "string" ? "text:" : "binary:").update(message).digest("hex");
53
110
  let value: MatchKey;
@@ -77,6 +134,16 @@ export function validateWebSocketRecording(record: CassetteWebSocket): void {
77
134
  || !Array.isArray(record.messages)) {
78
135
  throw new Error("invalid WebSocket recording: expected request, input hashes and messages");
79
136
  }
137
+ if (record.inputMode !== undefined && record.inputMode !== "anchors") throw new Error("invalid WebSocket input mode");
138
+ if (record.inputMode === "anchors") {
139
+ if (!Array.isArray(record.inputOccurrences) || record.inputOccurrences.length !== record.inputs.length) throw new Error("invalid WebSocket anchor occurrences");
140
+ const seen = new Map<string, number>();
141
+ record.inputs.forEach((key, index) => {
142
+ const count = record.inputOccurrences![index];
143
+ if (!Number.isSafeInteger(count) || count < 1 || count <= (seen.get(key) ?? 0)) throw new Error("invalid WebSocket anchor occurrence order");
144
+ seen.set(key, count);
145
+ });
146
+ } else if (record.inputOccurrences !== undefined) throw new Error("WebSocket occurrences require anchor mode");
80
147
  for (const message of record.messages) {
81
148
  if (!message || typeof message.data !== "string" || !["text", "binary"].includes(message.type)) {
82
149
  throw new Error("invalid WebSocket output message");
@@ -127,13 +194,15 @@ export interface WebSocketSession {
127
194
 
128
195
  export function replayWebSocket(args: {
129
196
  recording: CassetteWebSocket;
130
- match: (message: ReplayWebSocketMessage) => string | null;
197
+ match: WebSocketMatcher;
131
198
  label: string;
132
199
  timeoutMs: number;
133
200
  lifetime?: FakeWebSocketLifetime;
134
201
  finished(session: WebSocketSession, error?: Error): void;
135
202
  }): { handlers: FakeWebSocketHandlers; session: WebSocketSession } {
136
203
  const { recording: record, label } = args;
204
+ if ((record.inputMode === "anchors") !== !!args.match.candidates) throw new Error(`${label}: websocketKey mode changed; re-record this cassette`);
205
+ const occurrences = new Map<string, number>();
137
206
  const lifetime = args.lifetime ?? new FakeWebSocketLifetime();
138
207
  let detach = () => {};
139
208
  let socket: FakeWebSocket | undefined;
@@ -163,25 +232,52 @@ export function replayWebSocket(args: {
163
232
  clearTimeout(deadline);
164
233
  if (position < record.inputs.length) deadline = setTimeout(() => fail(missing()), args.timeoutMs);
165
234
  };
235
+ const nextEvent = () => (record.messages[output] as CassetteWebSocketMessage | undefined)
236
+ ?? (record.close?.initiator === "server" ? record.close : undefined);
237
+ const starts = record.inputMode === "anchors"
238
+ ? [...record.messages, ...(record.close?.initiator === "server" ? [record.close] : [])]
239
+ .filter((event, index, events) => index === 0 || event.afterInput !== events[index - 1].afterInput)
240
+ : [];
241
+ const dueAt = (event: WebSocketTiming) => {
242
+ let due = barriers[event.afterInput] + event.delayMs;
243
+ // Input progress can be faster than the recording (e.g. cached TTS).
244
+ // Once a later anchor is reached, earlier output must not hold its response
245
+ // behind the old clock. Catch up in order, without dropping any messages.
246
+ for (const next of starts) {
247
+ if (next.afterInput > event.afterInput && barriers[next.afterInput] !== undefined) {
248
+ due = Math.min(due, barriers[next.afterInput] + next.delayMs);
249
+ }
250
+ }
251
+ return due;
252
+ };
166
253
  const schedule = () => {
167
254
  if (stopped || !socket || sentClose) return;
168
255
  clearTimeout(timer);
169
- const event = (record.messages[output] as CassetteWebSocketMessage | undefined) ?? (record.close?.initiator === "server" ? record.close : undefined);
256
+ const event = nextEvent();
170
257
  if (!event || barriers[event.afterInput] === undefined) return;
171
- const delay = Math.max(0, barriers[event.afterInput] + event.delayMs - performance.now());
172
258
  timer = setTimeout(() => {
173
259
  if (stopped) return;
174
260
  try {
175
- if ("data" in event) {
176
- socket!.send(event.type === "text" ? event.data : new Uint8Array(Buffer.from(event.data, "base64")));
177
- output++;
178
- schedule();
179
- } else {
180
- sentClose = true;
181
- closeSocket(socket!, event.code, event.reason);
261
+ // Flush overdue output together; one timer per message would itself
262
+ // add latency when catching up a continuous stream of quiet audio.
263
+ for (let batch = 0; batch < MAX_PENDING && !stopped; batch++) {
264
+ const event = nextEvent();
265
+ if (!event || barriers[event.afterInput] === undefined) return;
266
+ if (dueAt(event) > performance.now()) break;
267
+ if ("data" in event) {
268
+ const message = event.type === "text" ? event.data : new Uint8Array(Buffer.from(event.data, "base64"));
269
+ args.match.output?.(typeof message === "string" ? message : message.slice());
270
+ socket!.send(message);
271
+ output++;
272
+ } else {
273
+ sentClose = true;
274
+ closeSocket(socket!, event.code, event.reason);
275
+ return;
276
+ }
182
277
  }
278
+ schedule();
183
279
  } catch (error) { fail(diagnostic(`playback failed: ${(error as Error).message}`)); }
184
- }, Math.min(delay, 2_147_483_647));
280
+ }, Math.min(Math.max(0, dueAt(event) - performance.now()), 2_147_483_647));
185
281
  };
186
282
  const session: WebSocketSession = {
187
283
  cancel() { finish(); socket?.close(1001, "replay cleanup"); },
@@ -197,11 +293,23 @@ export function replayWebSocket(args: {
197
293
  message(_ws, message) {
198
294
  if (stopped) return;
199
295
  try {
200
- const actual = args.match(message);
201
- if (actual === null) return;
202
- const expected = record.inputs[position];
203
- if (expected !== actual) throw new Error(`expected ${expected ?? "<exhausted>"}, actual ${actual}`);
204
- barriers[++position] = performance.now();
296
+ if (args.match.candidates) {
297
+ let matched = false;
298
+ for (const actual of args.match.candidates(message)) {
299
+ const count = occurrence(occurrences, actual);
300
+ if (actual !== record.inputs[position] || count !== record.inputOccurrences![position]) continue;
301
+ barriers[++position] = performance.now();
302
+ matched = true;
303
+ }
304
+ // Unselected candidates never extend the deadline.
305
+ if (!matched) return;
306
+ } else {
307
+ const actual = args.match(message);
308
+ if (actual === null) return;
309
+ const expected = record.inputs[position];
310
+ if (expected !== actual) throw new Error(`expected ${expected ?? "<exhausted>"}, actual ${actual}`);
311
+ barriers[++position] = performance.now();
312
+ }
205
313
  armDeadline();
206
314
  schedule();
207
315
  } catch (error) {
@@ -236,7 +344,7 @@ export async function recordWebSocket(args: {
236
344
  headers: Record<string, string>;
237
345
  protocols: string[];
238
346
  request: CassetteRequest;
239
- match: (message: ReplayWebSocketMessage) => string | null;
347
+ match: WebSocketMatcher;
240
348
  redact(value: string): string;
241
349
  secrets: { ref: string; value: string }[];
242
350
  label: string;
@@ -255,9 +363,21 @@ export async function recordWebSocket(args: {
255
363
  else callback(null, args.ip, 4);
256
364
  }) as any,
257
365
  });
258
- const record: CassetteWebSocket = { request: args.request, inputs: [], messages: [] };
366
+ const record: CassetteWebSocket = { request: args.request, inputs: [], messages: [],
367
+ ...(args.match.candidates ? { inputMode: "anchors" as const, inputOccurrences: [] } : {}),
368
+ };
369
+ const occurrences = new Map<string, number>();
370
+ let candidate: { key: string; count: number; at: number; index: number } | undefined;
371
+ let candidateIndex = 0, selectedIndex = 0;
259
372
  let socket: FakeWebSocket | undefined;
260
373
  let anchor = performance.now();
374
+ const selectCandidate = () => {
375
+ if (!candidate || candidate.index === selectedIndex) return;
376
+ record.inputs.push(candidate.key);
377
+ record.inputOccurrences!.push(candidate.count);
378
+ anchor = candidate.at;
379
+ selectedIndex = candidate.index;
380
+ };
261
381
  let stopped = false;
262
382
  let clientClosed = false;
263
383
  let upstreamClosure: { code: number; reason: string } | undefined;
@@ -300,6 +420,8 @@ export async function recordWebSocket(args: {
300
420
  const message = binary ? new Uint8Array(bytes) : bytes.toString();
301
421
  // Redact bytes too: base64 encoding must not hide a brokered secret.
302
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();
303
425
  record.messages.push({ ...timing(), type: binary ? "binary" : "text", data: redacted });
304
426
  if (socket) socket.send(message);
305
427
  else {
@@ -314,6 +436,7 @@ export async function recordWebSocket(args: {
314
436
  if (!opened) { fail(new Error("upstream closed before handshake completed")); return; }
315
437
  try {
316
438
  if (!clientClosed) {
439
+ selectCandidate();
317
440
  upstreamClosure = { code, reason: reason.toString() };
318
441
  record.close = { ...timing(), initiator: "server", code, reason: scrub(reason.toString()) };
319
442
  }
@@ -348,8 +471,14 @@ export async function recordWebSocket(args: {
348
471
  message(_ws, message) {
349
472
  if (stopped) return;
350
473
  try {
351
- const signature = args.match(typeof message === "string" ? message : Uint8Array.from(message));
352
- if (signature !== null) { record.inputs.push(signature); anchor = performance.now(); }
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
+ }
353
482
  const size = typeof message === "string" ? Buffer.byteLength(message) : message.byteLength;
354
483
  if (size + upstream.bufferedAmount > MAX_BUFFER) throw new Error("WebSocket upstream buffer exceeded");
355
484
  if (upstream.readyState !== WebSocket.OPEN) throw new Error("upstream is not open");
@@ -360,6 +489,7 @@ export async function recordWebSocket(args: {
360
489
  if (stopped || clientClosed) return;
361
490
  try {
362
491
  clientClosed = true;
492
+ selectCandidate();
363
493
  record.close = { ...timing(), initiator: "client", code, reason: scrub(reason) };
364
494
  closeSocket(upstream, code, reason);
365
495
  openingTimer = setTimeout(() => finish(), 1_000);