@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/src/phone.ts ADDED
@@ -0,0 +1,330 @@
1
+ import { spawn, type ChildProcessWithoutNullStreams } 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, type StepBlock } from "./recorder.js";
10
+ import { deepUnwrap } from "./inspect.js";
11
+
12
+ export interface PhoneOptions {
13
+ /** SIP server in the environment, e.g. sip:5060. */
14
+ endpoint: string;
15
+ /** Caller identity, e.g. sip:+46700000000@spectest.test. */
16
+ from: string;
17
+ transport?: "udp" | "tcp";
18
+ username?: string;
19
+ password?: string;
20
+ language?: string;
21
+ voice?: "M1" | "M2" | "M3" | "M4" | "M5" | "F1" | "F2" | "F3" | "F4" | "F5";
22
+ timeoutMs?: number;
23
+ }
24
+ export interface CallOptions {
25
+ /** Poll until true or a nonempty transcript string, then wait for incoming silence.
26
+ * A returned string is displayed as this step's recipient response. */
27
+ waitFor?: () => boolean | string | Promise<boolean | string>;
28
+ }
29
+ export interface SpeechOptions extends CallOptions {
30
+ language?: string;
31
+ voice?: PhoneOptions["voice"];
32
+ speed?: number;
33
+ }
34
+ export interface Phone {
35
+ /** Connect, optionally wait for a predicate then silence, and record one step. */
36
+ call(destination: string, options?: CallOptions): Promise<void>;
37
+ /** Send the complete utterance, optionally wait for a predicate then silence, and record one isolated step. */
38
+ say(text: string, options?: SpeechOptions): Promise<void>;
39
+ hangup(): Promise<void>;
40
+ /** Close the phone; also performed on success, failure, and test timeout. */
41
+ close(): Promise<void>;
42
+ }
43
+
44
+ /** Metadata only. Audio remains on disk until pulled through phoneRecordingChunk. */
45
+ export interface PhoneRecording {
46
+ id: string;
47
+ startedAtMs: number;
48
+ duration: number;
49
+ bytes: number;
50
+ contentType: "audio/webm";
51
+ encodingFinalizeMs?: number;
52
+ peaks: number[];
53
+ callerPeaks?: number[];
54
+ recipientPeaks?: number[];
55
+ error?: string;
56
+ }
57
+ type Reply = { id: number; error?: string; offset?: number };
58
+ type AudioBlock = Extract<StepBlock, { type: "audio" }>;
59
+ type PhoneStep = { playbackStart?: number; playbackEnd?: number; block: AudioBlock };
60
+ const MAX_RECORDING_BYTES = 96 * 1024 * 1024;
61
+ const parked = new Map<string, { path: string; directory: string; bytes: number }>();
62
+
63
+ export async function phoneRecordingChunk(id: string, offset: unknown, limit: unknown) {
64
+ const entry = parked.get(id);
65
+ if (!entry) throw new Error("Unknown phone recording");
66
+ const start = Number(offset ?? 0), count = Number(limit ?? 6 * 1024 * 1024);
67
+ if (!Number.isSafeInteger(start) || start < 0 || start > entry.bytes || !Number.isSafeInteger(count) || count < 1 || count > 6 * 1024 * 1024) throw new Error("Invalid phone recording range");
68
+ const file = await open(entry.path, "r");
69
+ try {
70
+ const buffer = Buffer.alloc(Math.min(count, entry.bytes - start));
71
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
72
+ return { b64: buffer.subarray(0, bytesRead).toString("base64"), offset: start, total: entry.bytes };
73
+ } finally { await file.close(); }
74
+ }
75
+ export async function releasePhoneRecording(id: string) {
76
+ const entry = parked.get(id);
77
+ if (entry) {
78
+ parked.delete(id);
79
+ await rm(entry.path, { force: true });
80
+ if (![...parked.values()].some(p => p.directory === entry.directory)) await rm(entry.directory, { recursive: true, force: true });
81
+ }
82
+ return { released: true };
83
+ }
84
+
85
+ class PhoneSession implements Phone {
86
+ private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; position?: (event: string, offset: number) => void }>();
87
+ private nextId = 0;
88
+ private closed = false;
89
+ private failure?: Error;
90
+ private cancellation = new AbortController();
91
+ private tail: Promise<unknown> = Promise.resolve();
92
+ private child: ChildProcessWithoutNullStreams;
93
+ private exited: Promise<void>;
94
+ private closing?: Promise<void>;
95
+ private directory?: string;
96
+ private activeCall?: string;
97
+ private calls = new Map<string, { started: number; steps: PhoneStep[]; recording?: PhoneRecording }>();
98
+ constructor(private options: PhoneOptions, private caseStart: number, private reserveCall: () => void) {
99
+ if (!/^[a-zA-Z0-9.-]+(?::\d{1,5})?$/.test(options.endpoint)) throw new Error("phone.endpoint must be a hostname or IPv4 address with optional port");
100
+ if (!/^sip:[^\s<>]+$/.test(options.from)) throw new Error("phone.from must be a sip: URI");
101
+ if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) throw new Error("phone.timeoutMs must be positive");
102
+ 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"] });
103
+ this.exited = new Promise(resolve => { this.child.once("close", () => resolve()); });
104
+ // Native logs can contain authentication headers. Never record them.
105
+ this.child.stderr.resume();
106
+ createInterface({ input: this.child.stdout }).on("line", line => {
107
+ let reply: Reply & { event?: string; sessionId?: string; offset?: number; startedAtMs: number; duration: number; bytes: number; peaks: number[]; callerPeaks?: number[]; recipientPeaks?: number[]; contentType: PhoneRecording["contentType"]; encodingFinalizeMs?: number };
108
+ try { reply = JSON.parse(line); } catch { return; }
109
+ if (reply.event === "recording" && reply.sessionId) {
110
+ const call = this.calls.get(reply.sessionId);
111
+ if (call) 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 };
112
+ return;
113
+ }
114
+ const waiter = this.pending.get(reply.id);
115
+ if (!waiter) return;
116
+ if (typeof reply.offset === "number" && Number.isFinite(reply.offset) && reply.offset >= 0) {
117
+ waiter.position?.(reply.event ?? "operation_ended", reply.offset);
118
+ }
119
+ if (reply.event === "speech_started" || reply.event === "operation_started") {
120
+ return;
121
+ }
122
+ clearTimeout(waiter.timer);
123
+ this.pending.delete(reply.id);
124
+ if (reply.error) waiter.reject(new Error(reply.error)); else waiter.resolve(reply);
125
+ });
126
+ this.child.on("error", () => this.fail(new Error("Phone runtime unavailable. Rebuild the golden image with phone support.")));
127
+ this.child.on("exit", () => this.fail(new Error("Phone runtime exited")));
128
+ this.child.stdin.on("error", () => this.fail(new Error("Phone runtime input closed")));
129
+ }
130
+ private fail(error: Error) {
131
+ this.closed = true;
132
+ this.failure ??= error;
133
+ this.cancellation.abort(this.failure);
134
+ for (const p of this.pending.values()) { clearTimeout(p.timer); p.reject(error); }
135
+ this.pending.clear();
136
+ }
137
+ private request(action: string, data: object = {}, timeout = this.options.timeoutMs ?? 30_000, position?: (event: string, offset: number) => void): Promise<Reply> {
138
+ if (this.closed) return Promise.reject(new Error("Phone is closed"));
139
+ const id = ++this.nextId;
140
+ return new Promise((resolve, reject) => {
141
+ const timer = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
142
+ this.pending.set(id, { resolve, reject, timer, position });
143
+ this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
144
+ });
145
+ }
146
+ async initialize(): Promise<this> {
147
+ this.directory = await mkdtemp(join(tmpdir(), "spectest-phone-"));
148
+ if (this.closed) throw this.failure ?? new Error("Phone is closed");
149
+ await this.request("open", { ...this.options, directory: this.directory }, 60_000);
150
+ return this;
151
+ }
152
+ private async waitForResponse(options: CallOptions): Promise<Reply & { response?: string }> {
153
+ const timeout = 30_000;
154
+ const deadline = performance.now() + timeout;
155
+ const controller = new AbortController();
156
+ const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
157
+ this.cancellation.signal.addEventListener("abort", cancel, { once: true });
158
+ if (this.closed) cancel();
159
+ const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
160
+ const aborted = new Promise<never>((_, reject) => {
161
+ const rejectAbort = () => reject(controller.signal.reason);
162
+ controller.signal.addEventListener("abort", rejectAbort, { once: true });
163
+ if (controller.signal.aborted) rejectAbort();
164
+ });
165
+ // Probes are observations: suppress their internal events without affecting
166
+ // concurrent phones, and discard late results after timeout/case cleanup.
167
+ const branch = captureRecording();
168
+ try {
169
+ return await Promise.race([aborted, branch.run(async () => {
170
+ let response: string | undefined;
171
+ if (options.waitFor) {
172
+ for (;;) {
173
+ controller.signal.throwIfAborted();
174
+ const ready = deepUnwrap(await options.waitFor());
175
+ controller.signal.throwIfAborted();
176
+ if (ready) {
177
+ if (typeof ready === "string") response = ready;
178
+ break;
179
+ }
180
+ await delay(100, undefined, { signal: controller.signal });
181
+ }
182
+ }
183
+ controller.signal.throwIfAborted();
184
+ const remaining = Math.max(1, deadline - performance.now());
185
+ const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
186
+ return { ...reply, response };
187
+ })]);
188
+ } finally {
189
+ clearTimeout(timer);
190
+ this.cancellation.signal.removeEventListener("abort", cancel);
191
+ controller.abort();
192
+ branch.finish(false);
193
+ }
194
+ }
195
+ private operation(action: string, title: string, data: object = {}, timeout?: number, options?: CallOptions): Promise<void> {
196
+ if (this.closed) return Promise.reject(new Error("Phone is closed"));
197
+ const work = this.tail.then(async () => {
198
+ if (this.closed) throw new Error("Phone is closed");
199
+ const reservation = reserveEvent(), start = Date.now();
200
+ if (action === "call") {
201
+ if (this.activeCall && !this.calls.get(this.activeCall)?.recording) throw new Error("Already in a call; hang up before dialing again");
202
+ this.reserveCall();
203
+ this.activeCall = randomUUID();
204
+ this.calls.set(this.activeCall, { started: start, steps: [] });
205
+ data = { ...data, sessionId: this.activeCall };
206
+ }
207
+ const text = action === "say" ? (data as { text: string }).text : "";
208
+ const blocks: StepBlock[] = [];
209
+ let step: PhoneStep | undefined;
210
+ if (this.activeCall) {
211
+ const block: AudioBlock = { type: "audio", sessionId: this.activeCall, text, start: 0, end: 0, duration: 0, peaks: [] };
212
+ if (action === "call" && options?.waitFor) block.waitedForSpeech = true;
213
+ step = { block };
214
+ this.calls.get(this.activeCall)!.steps.push(step);
215
+ // Retain the boundary for recording finalization, without rendering a
216
+ // player on the hangup step.
217
+ if (action !== "hangup") blocks.push(block);
218
+ } else if (text) blocks.push({ type: "text", text });
219
+ try {
220
+ let reply = await this.request(action, data, timeout, (event, offset) => {
221
+ if (!step) return;
222
+ if (event === "speech_started") step.playbackStart = offset;
223
+ if (event === "operation_started") {
224
+ step.playbackStart = offset;
225
+ const steps = this.calls.get(this.activeCall!)!.steps;
226
+ const previous = steps.at(-2);
227
+ // Dialing owns the greeting/idle audio up to the next operation.
228
+ if (previous && previous.playbackEnd === undefined) previous.playbackEnd = offset;
229
+ }
230
+ if (event === "operation_ended") step.playbackEnd = offset;
231
+ });
232
+ if (options?.waitFor) {
233
+ const waited = await this.waitForResponse(options);
234
+ reply = waited;
235
+ if (step && waited.response !== undefined) step.block.response = waited.response;
236
+ }
237
+ if ((action === "say" || options?.waitFor) && step) {
238
+ step.playbackEnd = reply.offset;
239
+ }
240
+ recordStep({ kind: "phone", title, blocks, status: "passed", durationMs: Date.now() - start }, reservation);
241
+ if (action === "hangup") this.activeCall = undefined;
242
+ } catch (error) {
243
+ if ((action === "say" || action === "call") && step) {
244
+ // A predicate can fail independently of the native transport. Sample
245
+ // its current position too; wall time is not an audio-file offset.
246
+ const position = this.closed ? undefined : await this.request("position").catch(() => undefined);
247
+ step.playbackEnd = this.closed ? undefined : position?.offset ?? step.playbackEnd;
248
+ }
249
+ recordStep({ kind: "phone", title, blocks, status: "failed", error: String(error), durationMs: Date.now() - start }, reservation);
250
+ throw error;
251
+ }
252
+ });
253
+ this.tail = work.catch(() => {});
254
+ return work;
255
+ }
256
+ call(destination: string, options: CallOptions = {}) {
257
+ if (!destination || /[\s<>\r\n]/.test(destination)) return Promise.reject(new Error("Invalid phone destination"));
258
+ if (options.waitFor !== undefined && typeof options.waitFor !== "function") return Promise.reject(new Error("phone.call waitFor must be a predicate"));
259
+ return this.operation("call", `Call ${destination}`, { destination }, undefined, options);
260
+ }
261
+ say(text: string, options: SpeechOptions = {}) {
262
+ if (!text.trim() || text.length > 2000) 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)) return Promise.reject(new Error("Speech speed must be between 0.7 and 2"));
264
+ if (options.waitFor !== undefined && typeof options.waitFor !== "function") return Promise.reject(new Error("phone.say waitFor must be a predicate"));
265
+ const { waitFor, ...voice } = options;
266
+ return this.operation("say", `Say ${JSON.stringify(text)}`, { ...voice, language: voice.language ?? this.options.language ?? "en", text }, 180_000, { waitFor });
267
+ }
268
+ hangup() { return this.operation("hangup", "Hang up"); }
269
+ close(): Promise<void> { return this.closing ??= this.dispose(); }
270
+ private async dispose() {
271
+ this.fail(new Error("Phone closed"));
272
+ this.child.stdin.end();
273
+ // EOF interrupts synthesis/playback, hangs up, and seals the recording.
274
+ const timer = setTimeout(() => this.child.kill("SIGKILL"), 10_000);
275
+ await this.exited;
276
+ clearTimeout(timer);
277
+ await this.tail;
278
+ }
279
+ async finish(record: boolean): Promise<PhoneRecording[]> {
280
+ await this.close();
281
+ const recordings: PhoneRecording[] = [];
282
+ for (const [id, call] of this.calls) {
283
+ const r: PhoneRecording = 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" };
284
+ const path = join(this.directory!, `${id}.webm`);
285
+ if (!r.error) {
286
+ const size = (await stat(path).catch(() => null))?.size;
287
+ if (size !== r.bytes || r.bytes < 44 || r.bytes > MAX_RECORDING_BYTES) r.error = "Call recording is missing or exceeds the size limit";
288
+ }
289
+ // Say owns transmission through its optional response wait. Unrelated
290
+ // work and later idle time stay only in the continuous call recording.
291
+ // Dial/hangup retain the surrounding call audio (including the greeting).
292
+ for (let i = 0; i < call.steps.length; i++) {
293
+ const step = call.steps[i]!;
294
+ const end = Math.min(r.duration, step.playbackEnd ?? call.steps[i + 1]?.playbackStart ?? r.duration);
295
+ Object.assign(step.block, { duration: r.duration, start: Math.min(end, step.playbackStart ?? 0), end });
296
+ }
297
+ if (record && !r.error) parked.set(id, { path, directory: this.directory!, bytes: r.bytes });
298
+ recordings.push(r);
299
+ }
300
+ if (this.directory && ![...parked.values()].some(p => p.directory === this.directory)) await rm(this.directory, { recursive: true, force: true });
301
+ return record ? recordings : [];
302
+ }
303
+ }
304
+
305
+ export function phoneScope(caseStart = Date.now(), { record = true } = {}) {
306
+ const sessions = new Set<PhoneSession>();
307
+ let closed = false, calls = 0;
308
+ let finished: Promise<PhoneRecording[]> | undefined;
309
+ // A fork inherits its parent's completed files. Discard those before opening
310
+ // this case's phones; the parent VM retains its own copies for archival.
311
+ const cleanup = (async () => {
312
+ // Calls from one phone share a directory; retire files sequentially so
313
+ // only the last release removes it.
314
+ for (const id of [...parked.keys()]) await releasePhoneRecording(id);
315
+ })();
316
+ return {
317
+ async open(options: PhoneOptions): Promise<Phone> {
318
+ await cleanup;
319
+ if (closed) throw new Error("Phone test scope has ended");
320
+ const session = new PhoneSession(options, caseStart, () => { if (++calls > 8) throw new Error("A case can record at most eight calls"); });
321
+ sessions.add(session);
322
+ try { return await session.initialize(); } catch (error) { await session.finish(false); throw error; }
323
+ },
324
+ async close() { closed = true; await Promise.all([...sessions].map(s => s.close())); },
325
+ finish() {
326
+ closed = true;
327
+ return finished ??= Promise.all([...sessions].map(s => s.finish(record))).then(records => records.flat());
328
+ },
329
+ };
330
+ }
package/src/recorder.ts CHANGED
@@ -964,6 +964,7 @@ export function recordEnv(
964
964
  * newer SDK's step degrades to the blocks the server knows.
965
965
  */
966
966
  export type StepBlock =
967
+ | { type: "audio"; sessionId: string; start: number; end: number; text: string; duration: number; peaks: number[]; waitedForSpeech?: boolean; response?: string }
967
968
  | { type: "text"; text: string }
968
969
  | { type: "code"; code: string; lang?: string; label?: string }
969
970
  | { type: "json"; value: unknown; label?: string }