@specific.dev/spectest 0.111.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 +14 -0
- package/dist/harness/phone.py +427 -0
- package/dist/harness/phone_recording.py +95 -0
- package/dist/index.d.ts +8 -0
- 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 +14 -0
- package/src/harness/phone.py +427 -0
- package/src/harness/phone_recording.py +95 -0
- package/src/index.ts +8 -0
- package/src/phone.test.ts +474 -0
- package/src/phone.ts +330 -0
- package/src/recorder.ts +1 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { phoneScope, phoneRecordingChunk, releasePhoneRecording } from "./phone.js";
|
|
6
|
+
import { recordStep, startRecording, stopRecording } from "./recorder.js";
|
|
7
|
+
import { trackHelpers } from "./helper-tracking.js";
|
|
8
|
+
|
|
9
|
+
let dir: string;
|
|
10
|
+
const original = process.env.SPECTEST_PHONE_PYTHON;
|
|
11
|
+
let scope = phoneScope();
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
dir = await mkdtemp(join(tmpdir(), "spectest-phone-test-"));
|
|
14
|
+
const runtime = join(dir, "runtime");
|
|
15
|
+
await writeFile(
|
|
16
|
+
runtime,
|
|
17
|
+
`#!/usr/bin/env bun
|
|
18
|
+
import { createInterface } from "node:readline";
|
|
19
|
+
let connected = false, directory, sessionId, started, clockRate = 1;
|
|
20
|
+
const offset = () => (Date.now() - started) / 1000 * clockRate;
|
|
21
|
+
function finish() {
|
|
22
|
+
if (!sessionId) return;
|
|
23
|
+
const duration = offset();
|
|
24
|
+
const bytes = Buffer.alloc(128);
|
|
25
|
+
bytes.set([0x1a,0x45,0xdf,0xa3]);
|
|
26
|
+
require("node:fs").writeFileSync(directory + "/" + sessionId + ".webm", bytes);
|
|
27
|
+
console.log(JSON.stringify({event:"recording", sessionId, startedAtMs:started, duration, contentType:"audio/webm", encodingFinalizeMs:1, bytes:bytes.length, peaks:[.2,.8], callerPeaks:[.2,0], recipientPeaks:[0,.8]}));
|
|
28
|
+
sessionId = undefined;
|
|
29
|
+
}
|
|
30
|
+
createInterface({ input: process.stdin }).on("line", async line => {
|
|
31
|
+
const data = JSON.parse(line);
|
|
32
|
+
let result = {};
|
|
33
|
+
if (sessionId && data.action !== "call") console.log(JSON.stringify({event:"operation_started", id:data.id, offset:offset()}));
|
|
34
|
+
if (data.action === "open") directory = data.directory;
|
|
35
|
+
if (data.action === "call") {
|
|
36
|
+
sessionId = data.sessionId; started = Date.now();
|
|
37
|
+
clockRate = data.destination === "clock-drift" ? 0.25 : 1;
|
|
38
|
+
console.log(JSON.stringify({event:"operation_started", id:data.id, offset:0}));
|
|
39
|
+
if (data.destination === "timeout") return;
|
|
40
|
+
await Bun.sleep(20);
|
|
41
|
+
connected = true;
|
|
42
|
+
}
|
|
43
|
+
if (data.action === "hangup") { connected = false; finish(); }
|
|
44
|
+
if (data.action === "say") {
|
|
45
|
+
result = { error: "No connected call" };
|
|
46
|
+
if (connected) {
|
|
47
|
+
// Generation completes before any speech is sent. The start event must
|
|
48
|
+
// not resolve say(), and must survive a later playback failure.
|
|
49
|
+
await Bun.sleep(80);
|
|
50
|
+
console.log(JSON.stringify({event:"speech_started", id:data.id, sessionId, offset:offset()}));
|
|
51
|
+
await Bun.sleep(30);
|
|
52
|
+
result = data.text === "Interrupted speech"
|
|
53
|
+
? { error: "Call disconnected during playback" }
|
|
54
|
+
: { duration: .03, offset:offset() };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (data.action === "wait_silence") {
|
|
58
|
+
await Bun.sleep(Math.min(data.silenceMs, data.timeoutMs));
|
|
59
|
+
result = data.silenceMs > data.timeoutMs
|
|
60
|
+
? {error:"SIP operation timed out"}
|
|
61
|
+
: {offset:offset()};
|
|
62
|
+
}
|
|
63
|
+
if (data.action === "position") result = {offset:offset()};
|
|
64
|
+
console.log(JSON.stringify({ id: data.id, ...result }));
|
|
65
|
+
}).on("close", () => { finish(); process.exit(0); });
|
|
66
|
+
`,
|
|
67
|
+
);
|
|
68
|
+
await chmod(runtime, 0o755);
|
|
69
|
+
process.env.SPECTEST_PHONE_PYTHON = runtime;
|
|
70
|
+
});
|
|
71
|
+
afterEach(async () => {
|
|
72
|
+
await scope.finish();
|
|
73
|
+
scope = phoneScope();
|
|
74
|
+
stopRecording();
|
|
75
|
+
});
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
if (original === undefined) delete process.env.SPECTEST_PHONE_PYTHON;
|
|
78
|
+
else process.env.SPECTEST_PHONE_PYTHON = original;
|
|
79
|
+
await rm(dir, { recursive: true, force: true });
|
|
80
|
+
});
|
|
81
|
+
const options = {
|
|
82
|
+
endpoint: "sip:5060",
|
|
83
|
+
from: "sip:caller@spectest.test",
|
|
84
|
+
password: "never-record-this",
|
|
85
|
+
language: "sv",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
test("serializes dialing and speech, preserves the full utterance and excludes credentials", async () => {
|
|
89
|
+
startRecording();
|
|
90
|
+
const phone = await scope.open(options);
|
|
91
|
+
await Promise.all([
|
|
92
|
+
phone.call("123"),
|
|
93
|
+
phone.say("Hej! Jag vill boka en tid."),
|
|
94
|
+
]);
|
|
95
|
+
await phone.hangup();
|
|
96
|
+
const events = stopRecording();
|
|
97
|
+
expect(events.map((e) => e.kind)).toEqual(["phone", "phone", "phone"]);
|
|
98
|
+
expect(events.map((e) => e.seq)).toEqual([0, 1, 2]);
|
|
99
|
+
const say = events[1] as any;
|
|
100
|
+
expect(say.status).toBe("passed");
|
|
101
|
+
expect(say.blocks[0].text).toBe("Hej! Jag vill boka en tid.");
|
|
102
|
+
expect((events[0] as any).blocks[0].waitedForSpeech).toBeUndefined();
|
|
103
|
+
expect(say.blocks[0].waitedForSpeech).toBeUndefined();
|
|
104
|
+
expect(say.blocks[0]).not.toHaveProperty("segments");
|
|
105
|
+
expect(JSON.stringify(events)).not.toContain(options.password);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("failed speech retains text and scope cleanup fences later operations", async () => {
|
|
109
|
+
startRecording();
|
|
110
|
+
const phone = await scope.open(options);
|
|
111
|
+
await expect(phone.say("Hello.")).rejects.toThrow("No connected call");
|
|
112
|
+
const events = stopRecording() as any[];
|
|
113
|
+
expect(events[0].status).toBe("failed");
|
|
114
|
+
expect(events[0].blocks).toEqual([{ type: "text", text: "Hello." }]);
|
|
115
|
+
await scope.close();
|
|
116
|
+
startRecording();
|
|
117
|
+
await expect(phone.say("Late work")).rejects.toThrow("closed");
|
|
118
|
+
await expect(scope.open(options)).rejects.toThrow("ended");
|
|
119
|
+
expect(stopRecording()).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("times out an unanswered call and closes without leaking a subprocess", async () => {
|
|
123
|
+
const phone = await scope.open({ ...options, timeoutMs: 40 });
|
|
124
|
+
await expect(phone.call("timeout")).rejects.toThrow("timed out");
|
|
125
|
+
await phone.close();
|
|
126
|
+
await phone.close();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("cancels an in-flight operation when a case ends", async () => {
|
|
130
|
+
const phone = await scope.open(options);
|
|
131
|
+
const call = phone.call("timeout");
|
|
132
|
+
const rejected = call.catch((error) => error);
|
|
133
|
+
await Bun.sleep(5);
|
|
134
|
+
await scope.close();
|
|
135
|
+
expect(String(await rejected)).toContain("closed");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("rejects invalid inputs before contacting SIP", async () => {
|
|
139
|
+
await expect(
|
|
140
|
+
scope.open({ ...options, endpoint: "sip:5060\r\nInjected: header" }),
|
|
141
|
+
).rejects.toThrow("endpoint");
|
|
142
|
+
const phone = await scope.open(options);
|
|
143
|
+
await expect(phone.say(" ")).rejects.toThrow("characters");
|
|
144
|
+
await expect(phone.say("x".repeat(2001))).rejects.toThrow("characters");
|
|
145
|
+
await expect(phone.say("hello", { speed: NaN })).rejects.toThrow("speed");
|
|
146
|
+
await expect(phone.call("a\r\nb")).rejects.toThrow("destination");
|
|
147
|
+
await expect(phone.call("123", { waitFor: true as any })).rejects.toThrow("predicate");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("reports a missing golden runtime and still completes cleanup", async () => {
|
|
151
|
+
const runtime = process.env.SPECTEST_PHONE_PYTHON;
|
|
152
|
+
process.env.SPECTEST_PHONE_PYTHON = join(dir, "missing-runtime");
|
|
153
|
+
try {
|
|
154
|
+
await expect(scope.open(options)).rejects.toThrow("runtime unavailable");
|
|
155
|
+
await scope.close();
|
|
156
|
+
} finally {
|
|
157
|
+
process.env.SPECTEST_PHONE_PYTHON = runtime;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
test("keeps one whole-call file across speech and idle gaps, with bounded chunk transfer", async () => {
|
|
163
|
+
startRecording();
|
|
164
|
+
const phone = await scope.open(options);
|
|
165
|
+
await phone.call("123");
|
|
166
|
+
await phone.say("One. Two.");
|
|
167
|
+
await Bun.sleep(60);
|
|
168
|
+
await phone.say("Three.");
|
|
169
|
+
await Bun.sleep(40);
|
|
170
|
+
// No hangup: finishing the case must seal the idle tail.
|
|
171
|
+
const records = await scope.finish();
|
|
172
|
+
const events = stopRecording() as any[];
|
|
173
|
+
expect(records).toHaveLength(1);
|
|
174
|
+
expect(records[0]!.error).toBeUndefined();
|
|
175
|
+
expect(records[0]!.duration).toBeGreaterThan(.1);
|
|
176
|
+
const first = events[1].blocks[0], second = events[2].blocks[0];
|
|
177
|
+
expect(first.sessionId).toBe(second.sessionId);
|
|
178
|
+
expect(first.end - first.start).toBeGreaterThanOrEqual(.02);
|
|
179
|
+
expect(records[0]!.duration - second.end).toBeGreaterThanOrEqual(.03);
|
|
180
|
+
expect(second.end - second.start).toBeGreaterThanOrEqual(.02);
|
|
181
|
+
expect(JSON.stringify(events)).not.toContain('"audio":');
|
|
182
|
+
const chunk = await phoneRecordingChunk(records[0]!.id, 0, 44);
|
|
183
|
+
expect([...Buffer.from(chunk.b64, "base64").subarray(0,4)]).toEqual([0x1a,0x45,0xdf,0xa3]);
|
|
184
|
+
expect(records[0]!.contentType).toBe("audio/webm");
|
|
185
|
+
expect(records[0]!.encodingFinalizeMs).toBeGreaterThanOrEqual(0);
|
|
186
|
+
expect(chunk.total).toBe(records[0]!.bytes);
|
|
187
|
+
await expect(phoneRecordingChunk(records[0]!.id, -1, 44)).rejects.toThrow("range");
|
|
188
|
+
await releasePhoneRecording(records[0]!.id);
|
|
189
|
+
await expect(phoneRecordingChunk(records[0]!.id, 0, 44)).rejects.toThrow("Unknown");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("separates consecutive calls and discards unrecorded evaluation files", async () => {
|
|
193
|
+
const phone = await scope.open(options);
|
|
194
|
+
await phone.call("123");
|
|
195
|
+
await phone.hangup();
|
|
196
|
+
await phone.call("456");
|
|
197
|
+
const records = await scope.finish();
|
|
198
|
+
expect(records).toHaveLength(2);
|
|
199
|
+
expect(records[0]!.id).not.toBe(records[1]!.id);
|
|
200
|
+
scope = phoneScope(Date.now(), {record:false});
|
|
201
|
+
await expect(phoneRecordingChunk(records[0]!.id, 0, 44)).rejects.toThrow("Unknown");
|
|
202
|
+
const evaluated = await scope.open(options);
|
|
203
|
+
await evaluated.call("789");
|
|
204
|
+
expect(await scope.finish()).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
test("isolates say clips from unrelated waits and leaves synthesis in the full call", async () => {
|
|
209
|
+
startRecording();
|
|
210
|
+
const phone = await scope.open(options);
|
|
211
|
+
await phone.call("123");
|
|
212
|
+
await phone.say("First utterance");
|
|
213
|
+
await Bun.sleep(40); // Unrelated work, outside the step.
|
|
214
|
+
await phone.say("Second utterance");
|
|
215
|
+
await Bun.sleep(40);
|
|
216
|
+
await phone.hangup();
|
|
217
|
+
const [recording] = await scope.finish();
|
|
218
|
+
const events = stopRecording() as any[];
|
|
219
|
+
const [call, first, second] = events.map(e => e.blocks[0]);
|
|
220
|
+
// Each clip skips the fixture's synthesis delay and ends at its own completion.
|
|
221
|
+
expect(first.start - call.end).toBeGreaterThanOrEqual(.06);
|
|
222
|
+
expect(second.start - first.end).toBeGreaterThanOrEqual(.06);
|
|
223
|
+
// The speech_started event must not resolve the request before playback ends.
|
|
224
|
+
expect(first.end - first.start).toBeGreaterThanOrEqual(.02);
|
|
225
|
+
expect(second.end - second.start).toBeGreaterThanOrEqual(.02);
|
|
226
|
+
expect(recording!.duration - second.end).toBeGreaterThanOrEqual(.03);
|
|
227
|
+
expect(events[3].blocks).toEqual([]);
|
|
228
|
+
expect(events[1].durationMs).toBeGreaterThanOrEqual(100);
|
|
229
|
+
expect(recording!.duration).toBeGreaterThan(.3);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("retains the transmission boundary when playback fails after speech starts", async () => {
|
|
233
|
+
startRecording();
|
|
234
|
+
const phone = await scope.open(options);
|
|
235
|
+
await phone.call("123");
|
|
236
|
+
await expect(phone.say("Interrupted speech")).rejects.toThrow("disconnected");
|
|
237
|
+
const [recording] = await scope.finish();
|
|
238
|
+
const events = stopRecording() as any[];
|
|
239
|
+
const call = events[0].blocks[0], say = events[1].blocks[0];
|
|
240
|
+
expect(events[1].status).toBe("failed");
|
|
241
|
+
expect(say.start - call.end).toBeGreaterThanOrEqual(.06);
|
|
242
|
+
expect(say.end - say.start).toBeGreaterThanOrEqual(.02);
|
|
243
|
+
expect(say.end).toBeLessThanOrEqual(recording!.duration);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("maps every clip to PCM positions when the media clock falls behind wall time", async () => {
|
|
247
|
+
startRecording();
|
|
248
|
+
const phone = await scope.open(options);
|
|
249
|
+
await phone.call("clock-drift");
|
|
250
|
+
await phone.say("First");
|
|
251
|
+
await Bun.sleep(80);
|
|
252
|
+
await phone.say("Second");
|
|
253
|
+
await Bun.sleep(80);
|
|
254
|
+
await phone.hangup();
|
|
255
|
+
const [recording] = await scope.finish();
|
|
256
|
+
const events = stopRecording() as any[];
|
|
257
|
+
const [call, first, second] = events.map(e => e.blocks[0]);
|
|
258
|
+
expect(call.start).toBe(0);
|
|
259
|
+
expect(call.end).toBeLessThan(first.start);
|
|
260
|
+
expect(first.end).toBeLessThan(second.start);
|
|
261
|
+
expect(second.end).toBeLessThan(recording!.duration);
|
|
262
|
+
expect(events[3].blocks).toEqual([]);
|
|
263
|
+
// Each 30 ms utterance occupies only ~8 ms on this deliberately slow clock.
|
|
264
|
+
expect(first.end - first.start).toBeLessThan(.025);
|
|
265
|
+
expect(second.end - second.start).toBeLessThan(.025);
|
|
266
|
+
expect(recording!.duration).toBeLessThan(.2);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
test("call waits for its greeting predicate then silence before the next say, and isolates its clip", async () => {
|
|
271
|
+
startRecording();
|
|
272
|
+
const phone = await scope.open(options);
|
|
273
|
+
let probes = 0, satisfiedAt = 0, finishedAt = 0;
|
|
274
|
+
const call = phone.call("123", {
|
|
275
|
+
waitFor: async () => {
|
|
276
|
+
recordStep({ kind: "probe", title: "Greeting observation" });
|
|
277
|
+
if (++probes < 3) return false;
|
|
278
|
+
satisfiedAt = Date.now();
|
|
279
|
+
return true;
|
|
280
|
+
},
|
|
281
|
+
}).then(() => { finishedAt = Date.now(); });
|
|
282
|
+
const say = phone.say("Hello");
|
|
283
|
+
await Promise.all([call, say]);
|
|
284
|
+
await scope.finish();
|
|
285
|
+
const events = stopRecording() as any[];
|
|
286
|
+
expect(probes).toBe(3);
|
|
287
|
+
expect(finishedAt - satisfiedAt).toBeGreaterThanOrEqual(650);
|
|
288
|
+
expect(events.map(e => e.kind)).toEqual(["phone", "phone"]);
|
|
289
|
+
const greeting = events[0].blocks[0], reply = events[1].blocks[0];
|
|
290
|
+
expect(greeting.waitedForSpeech).toBe(true);
|
|
291
|
+
expect(greeting.start).toBe(0);
|
|
292
|
+
expect(greeting.end).toBeGreaterThanOrEqual(.85);
|
|
293
|
+
expect(reply.start - greeting.end).toBeGreaterThanOrEqual(.06);
|
|
294
|
+
expect(events[0].durationMs).toBeGreaterThanOrEqual(850);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("waitFor stores per-step transcript strings after silence and keeps boolean waits compatible", async () => {
|
|
298
|
+
startRecording();
|
|
299
|
+
const phone = await scope.open(options);
|
|
300
|
+
await phone.call("123", { waitFor: () => "Welcome. How can I help?" });
|
|
301
|
+
let probes = 0, returnedAt = 0;
|
|
302
|
+
const response = "Thursday at ten is available.\nWould that work for you?";
|
|
303
|
+
const fake = trackHelpers("fake", "voice", { transcript: async () => {
|
|
304
|
+
recordStep({ kind: "probe", title: "Read delivered transcript" });
|
|
305
|
+
if (++probes === 1) return false;
|
|
306
|
+
if (probes === 2) return "";
|
|
307
|
+
returnedAt = Date.now();
|
|
308
|
+
return response;
|
|
309
|
+
}});
|
|
310
|
+
await phone.say("A checkup, please", { waitFor: () => fake.transcript() });
|
|
311
|
+
expect(Date.now() - returnedAt).toBeGreaterThanOrEqual(650);
|
|
312
|
+
await phone.say("Yes", { waitFor: () => true });
|
|
313
|
+
await phone.say("Thanks");
|
|
314
|
+
await scope.finish();
|
|
315
|
+
const events = stopRecording() as any[];
|
|
316
|
+
expect(events.map(e => e.kind)).toEqual(["phone", "phone", "phone", "phone"]);
|
|
317
|
+
expect(probes).toBe(3);
|
|
318
|
+
expect(events[0].blocks[0].response).toBe("Welcome. How can I help?");
|
|
319
|
+
expect(events[1].blocks[0].response).toBe(response);
|
|
320
|
+
expect(events[2].blocks[0].response).toBeUndefined();
|
|
321
|
+
expect(events[3].blocks[0].response).toBeUndefined();
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a greeting predicate failure fails the call step and preserves its recorded window", async () => {
|
|
325
|
+
startRecording();
|
|
326
|
+
const phone = await scope.open(options);
|
|
327
|
+
await expect(phone.call("123", { waitFor: () => { throw new Error("Greeting failed"); } })).rejects.toThrow("Greeting failed");
|
|
328
|
+
await Bun.sleep(60);
|
|
329
|
+
await phone.hangup();
|
|
330
|
+
const [recording] = await scope.finish();
|
|
331
|
+
const events = stopRecording() as any[];
|
|
332
|
+
expect(events[0].status).toBe("failed");
|
|
333
|
+
expect(events[0].blocks[0].start).toBe(0);
|
|
334
|
+
expect(events[0].blocks[0].end).toBeGreaterThan(0);
|
|
335
|
+
expect(recording!.duration - events[0].blocks[0].end).toBeGreaterThanOrEqual(.05);
|
|
336
|
+
expect(events[1].blocks).toEqual([]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("waits for the predicate then a fresh silence window inside one serialized say step", async () => {
|
|
340
|
+
startRecording();
|
|
341
|
+
const phone = await scope.open(options);
|
|
342
|
+
await phone.call("123");
|
|
343
|
+
let probes = 0, satisfiedAt = 0, finishedAt = 0;
|
|
344
|
+
const first = phone.say("Question", {
|
|
345
|
+
waitFor: async () => {
|
|
346
|
+
recordStep({ kind: "probe", title: "Internal observation" });
|
|
347
|
+
if (++probes < 3) return false;
|
|
348
|
+
satisfiedAt = Date.now();
|
|
349
|
+
return true;
|
|
350
|
+
},
|
|
351
|
+
}).then(() => { finishedAt = Date.now(); });
|
|
352
|
+
const second = phone.say("Next turn");
|
|
353
|
+
await Promise.all([first, second]);
|
|
354
|
+
await Bun.sleep(50);
|
|
355
|
+
const [recording] = await scope.finish();
|
|
356
|
+
const events = stopRecording() as any[];
|
|
357
|
+
expect(probes).toBe(3);
|
|
358
|
+
expect(finishedAt - satisfiedAt).toBeGreaterThanOrEqual(50);
|
|
359
|
+
expect(events.map(e => e.kind)).toEqual(["phone", "phone", "phone"]);
|
|
360
|
+
const a = events[1].blocks[0], b = events[2].blocks[0];
|
|
361
|
+
expect(a.end - a.start).toBeGreaterThanOrEqual(.12);
|
|
362
|
+
expect(b.start - a.end).toBeGreaterThanOrEqual(.06);
|
|
363
|
+
expect(recording!.duration - b.end).toBeGreaterThanOrEqual(.04);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("predicate errors and hung predicates fail the say step without waiting for silence", async () => {
|
|
367
|
+
startRecording();
|
|
368
|
+
const phone = await scope.open(options);
|
|
369
|
+
await phone.call("123");
|
|
370
|
+
await expect(phone.say("Throw", { waitFor: () => { throw new Error("Application failed"); } })).rejects.toThrow("Application failed");
|
|
371
|
+
const nativeTimeout = globalThis.setTimeout;
|
|
372
|
+
const timer = spyOn(globalThis, "setTimeout").mockImplementation(((fn: any, ms: number, ...args: any[]) =>
|
|
373
|
+
nativeTimeout(fn, ms === 30_000 ? 40 : ms, ...args)) as typeof setTimeout);
|
|
374
|
+
try {
|
|
375
|
+
await expect(phone.say("Hang", { waitFor: () => new Promise(() => {}) })).rejects.toThrow("response wait timed out");
|
|
376
|
+
} finally { timer.mockRestore(); }
|
|
377
|
+
await scope.finish();
|
|
378
|
+
const events = stopRecording() as any[];
|
|
379
|
+
expect(events.slice(1).map(e => e.status)).toEqual(["failed", "failed"]);
|
|
380
|
+
expect(events[2].blocks[0].end - events[2].blocks[0].start).toBeGreaterThanOrEqual(.05);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("case cleanup cancels a hung predicate and suppresses its late events", async () => {
|
|
384
|
+
startRecording();
|
|
385
|
+
const phone = await scope.open(options);
|
|
386
|
+
await phone.call("123");
|
|
387
|
+
let began!: () => void, release!: () => void;
|
|
388
|
+
const entered = new Promise<void>(resolve => { began = resolve; });
|
|
389
|
+
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
390
|
+
const pending = phone.say("Question", { waitFor: async () => {
|
|
391
|
+
began();
|
|
392
|
+
await gate;
|
|
393
|
+
recordStep({ kind: "late", title: "Must not escape" });
|
|
394
|
+
return "Late transcript must not escape";
|
|
395
|
+
}}).catch(error => error);
|
|
396
|
+
await entered;
|
|
397
|
+
await scope.close();
|
|
398
|
+
expect(String(await pending)).toContain("closed");
|
|
399
|
+
release();
|
|
400
|
+
await Bun.sleep(10);
|
|
401
|
+
const events = stopRecording() as any[];
|
|
402
|
+
expect(events.map(e => e.kind)).toEqual(["phone", "phone"]);
|
|
403
|
+
expect(events[1].blocks[0].response).toBeUndefined();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("real harness exposes ctx.phone, seals recordings on every outcome and serves audio chunks", async () => {
|
|
407
|
+
// Use the actual project loader, test runner and method table. Isolate daemon
|
|
408
|
+
// globals/instrumentation from the unit tests; only the native phone subprocess is a fixture.
|
|
409
|
+
const root = join(dir, "project");
|
|
410
|
+
await mkdir(join(root, "spectest"), { recursive: true });
|
|
411
|
+
const sdk = new URL("./index.ts", import.meta.url).href;
|
|
412
|
+
const daemon = new URL("./daemon.ts", import.meta.url).href;
|
|
413
|
+
await writeFile(join(root, "spectest/index.ts"), `
|
|
414
|
+
import { defineEnvironment } from ${JSON.stringify(sdk)};
|
|
415
|
+
const env = defineEnvironment({ name: "phone-integration", services: {} });
|
|
416
|
+
const cases = ["success", "failure", "timeout"].map(outcome => env.test(outcome,
|
|
417
|
+
{ timeoutMs: outcome === "timeout" ? 350 : 2000 }, async ctx => {
|
|
418
|
+
const phone = await ctx.phone(${JSON.stringify(options)});
|
|
419
|
+
await phone.call("123");
|
|
420
|
+
await phone.say("One. Two.");
|
|
421
|
+
await new Promise(r => setTimeout(r, 30));
|
|
422
|
+
if (outcome === "failure") throw new Error("application assertion failed");
|
|
423
|
+
if (outcome === "timeout") await new Promise(() => {});
|
|
424
|
+
// The runner must close the live call before returning /run.
|
|
425
|
+
}));
|
|
426
|
+
export default env.project(cases);
|
|
427
|
+
`);
|
|
428
|
+
const script = `
|
|
429
|
+
import { strict as assert } from "node:assert";
|
|
430
|
+
import { METHODS } from ${JSON.stringify(daemon)};
|
|
431
|
+
const loaded = await METHODS.load({});
|
|
432
|
+
assert.equal(loaded.cases.length, 3);
|
|
433
|
+
for (const [index, test] of loaded.cases.entries()) {
|
|
434
|
+
const result = await METHODS.run({caseId:test.id});
|
|
435
|
+
assert.equal(result.status, index === 0 ? "passed" : "failed", JSON.stringify(result));
|
|
436
|
+
if (index === 1) assert.match(result.error.message, /application assertion failed/);
|
|
437
|
+
if (index === 2) assert.match(result.error.message, /timed out/);
|
|
438
|
+
assert.equal(result.phoneRecordings.length, 1);
|
|
439
|
+
const recording = result.phoneRecordings[0];
|
|
440
|
+
assert.equal(recording.error, undefined);
|
|
441
|
+
assert.ok(recording.duration > 0);
|
|
442
|
+
assert.deepEqual(recording.callerPeaks, [.2,0]);
|
|
443
|
+
assert.deepEqual(recording.recipientPeaks, [0,.8]);
|
|
444
|
+
const steps = result.events.filter(e => e.kind === "phone");
|
|
445
|
+
assert.equal(steps.length, 2);
|
|
446
|
+
assert.equal(steps[1].blocks[0].text, "One. Two.");
|
|
447
|
+
assert.equal(steps[1].blocks[0].sessionId, recording.id);
|
|
448
|
+
assert.ok(steps[1].blocks[0].end < recording.duration);
|
|
449
|
+
assert.ok(steps[1].blocks[0].end > steps[1].blocks[0].start);
|
|
450
|
+
assert.ok(!JSON.stringify(result).includes("never-record-this"));
|
|
451
|
+
assert.ok(!JSON.stringify(result).includes('"audio":'));
|
|
452
|
+
const chunk = await METHODS.phoneRecordingChunk({caseId:recording.id, offset:0, limit:44});
|
|
453
|
+
assert.equal(chunk.total, recording.bytes);
|
|
454
|
+
assert.deepEqual([...Buffer.from(chunk.b64, "base64").subarray(0,4)], [0x1a,0x45,0xdf,0xa3]);
|
|
455
|
+
assert.equal(recording.contentType, "audio/webm");
|
|
456
|
+
assert.ok(recording.encodingFinalizeMs >= 0);
|
|
457
|
+
await METHODS.releasePhoneRecording({id:recording.id});
|
|
458
|
+
await assert.rejects(METHODS.phoneRecordingChunk({caseId:recording.id}), /Unknown/);
|
|
459
|
+
}
|
|
460
|
+
const evaluated = await METHODS.eval({code:
|
|
461
|
+
'const p = await ctx.phone(' + ${JSON.stringify(JSON.stringify(options))} + '); await p.call("123"); await p.say("Eval speech"); export default "done";'
|
|
462
|
+
});
|
|
463
|
+
assert.equal(evaluated.ok, true, JSON.stringify(evaluated));
|
|
464
|
+
assert.equal(evaluated.result, "done");
|
|
465
|
+
process.exit(0);
|
|
466
|
+
`;
|
|
467
|
+
const child = Bun.spawn([process.execPath, "--eval", script], {
|
|
468
|
+
env: { ...process.env, SPECTEST_APP_DIR: root }, stdout: "pipe", stderr: "pipe",
|
|
469
|
+
});
|
|
470
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
471
|
+
child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(),
|
|
472
|
+
]);
|
|
473
|
+
expect(code, stdout + stderr).toBe(0);
|
|
474
|
+
}, 20_000);
|