@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/README.md +41 -0
- package/dist/components/replay-websocket.d.ts +29 -3
- package/dist/components/replay-websocket.js +157 -25
- package/dist/components/replayFake.d.ts +4 -1
- package/dist/daemon.js +18 -3
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/index.d.ts +20 -7
- package/dist/index.js +2 -2
- package/dist/phone.d.ts +59 -0
- package/dist/phone.js +343 -0
- package/dist/recorder.d.ts +10 -0
- package/package.json +1 -1
- package/src/components/replay-websocket.ts +154 -24
- package/src/components/replayFake-handler.test.ts +5 -0
- package/src/components/replayFake-websocket.test.ts +166 -0
- package/src/components/replayFake.ts +4 -1
- package/src/daemon.ts +18 -3
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/index.ts +17 -8
- package/src/interactive-setup.test.ts +2 -2
- package/src/phone.test.ts +474 -0
- package/src/phone.ts +330 -0
- package/src/recorder.ts +1 -0
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
|
+
}
|
package/dist/recorder.d.ts
CHANGED
|
@@ -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
|
@@ -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)
|
|
43
|
-
|
|
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>):
|
|
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:
|
|
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 = (
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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(
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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:
|
|
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
|
|
352
|
-
if (
|
|
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);
|