@specific.dev/spectest 0.130.0 → 0.131.1
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 +5 -2
- package/dist/harness/phone.py +27 -18
- package/dist/harness/phone_recording.py +5 -1
- package/dist/phone.js +7 -7
- package/package.json +1 -1
- package/src/harness/phone.py +27 -18
- package/src/harness/phone_recording.py +5 -1
- package/src/phone.test.ts +26 -17
- package/src/phone.ts +8 -8
package/README.md
CHANGED
|
@@ -478,8 +478,11 @@ time, and ends when that step completes. Both `call(destination, { waitFor })`
|
|
|
478
478
|
and `say(text, { waitFor })` poll application state, then wait for incoming
|
|
479
479
|
silence. For `call`, polling starts after the call connects, allowing the greeting
|
|
480
480
|
to finish within the call step. For `say`, polling starts after outgoing playback.
|
|
481
|
-
Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The
|
|
482
|
-
|
|
481
|
+
Without `waitFor`, `call` ends when connected and `say` ends after outgoing playback. The
|
|
482
|
+
wait has no timeout of its own: the test case's `timeoutMs` is the only ceiling,
|
|
483
|
+
and a bound on the reply belongs inside the predicate (for example a `ctx.poll`
|
|
484
|
+
with its own `timeoutMs`), where it appears in the timeline. The quiet
|
|
485
|
+
period (700 ms) and poll interval (100 ms) are
|
|
483
486
|
internal defaults for now. `waitFor` may return a nonempty transcript string
|
|
484
487
|
instead of `true`; after the silence wait, it appears as **Response** below **Caller** in an indented chat row beneath the waveform. Return `false` or `""` to keep waiting. Your test supplies the text
|
|
485
488
|
from application state or a fake; an async callback can wait and then return it.
|
package/dist/harness/phone.py
CHANGED
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
import wave
|
|
15
15
|
import math
|
|
16
16
|
from array import array
|
|
17
|
-
from phone_recording import StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
17
|
+
from phone_recording import RecordingError, StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
18
18
|
|
|
19
19
|
# PJSIP writes directly to fd 1. Keep the protocol on a private duplicate.
|
|
20
20
|
protocol = os.fdopen(os.dup(1), "w", buffering=1)
|
|
@@ -179,22 +179,29 @@ class Call(pj.Call):
|
|
|
179
179
|
result = {"event": "recording", "sessionId": self.session_id,
|
|
180
180
|
"startedAtMs": self.started_at_ms, "duration": 0, "bytes": 0,
|
|
181
181
|
"contentType": "audio/webm", "peaks": []}
|
|
182
|
+
errors = [self.capture_error] if self.capture_error else []
|
|
182
183
|
try:
|
|
183
184
|
self.clock.stopTransmit(self.recorder)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
except Exception as error:
|
|
186
|
+
# Native error text can contain SIP identities or credentials.
|
|
187
|
+
errors.append(f"Could not disconnect recording clock ({type(error).__name__})")
|
|
188
|
+
if self.media is not None:
|
|
189
|
+
try:
|
|
190
|
+
self.media.stopTransmit(self.recorder)
|
|
191
|
+
self.media.stopTransmit(self.recipient_meter)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass # The remote peer may already have removed its port.
|
|
194
|
+
try:
|
|
195
|
+
# Always close the sink and reap the encoder, even if detach failed.
|
|
190
196
|
result.update(self.recorder.finish())
|
|
191
197
|
peaks = result["peaks"]
|
|
192
198
|
for side, track in self.recorder.tracks.items():
|
|
193
199
|
result[side] = (track + [0.0] * max(0, len(peaks) - len(track)))[:len(peaks)]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
except Exception as error:
|
|
201
|
+
detail = str(error) if isinstance(error, RecordingError) else type(error).__name__
|
|
202
|
+
errors.append(f"Call recording could not be finalized: {detail}")
|
|
203
|
+
if errors:
|
|
204
|
+
result["error"] = "; ".join(errors)
|
|
198
205
|
protocol.write(json.dumps(result) + "\n")
|
|
199
206
|
|
|
200
207
|
|
|
@@ -271,12 +278,14 @@ def main():
|
|
|
271
278
|
else:
|
|
272
279
|
time.sleep(0.01)
|
|
273
280
|
|
|
274
|
-
def wait(predicate, timeout, live=False):
|
|
275
|
-
|
|
281
|
+
def wait(predicate, timeout=None, live=False):
|
|
282
|
+
# No timeout: run until satisfied, the call ends, or the SDK closes
|
|
283
|
+
# stdin (which happens at case timeout and cleanup).
|
|
284
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
276
285
|
while not predicate():
|
|
277
286
|
if live and call.disconnected:
|
|
278
287
|
raise ValueError(f"SIP call ended ({call.status})")
|
|
279
|
-
if time.monotonic() >= deadline:
|
|
288
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
280
289
|
raise ValueError("SIP operation timed out")
|
|
281
290
|
pump()
|
|
282
291
|
|
|
@@ -374,14 +383,14 @@ def main():
|
|
|
374
383
|
if not call or call.disconnected or call.media is None:
|
|
375
384
|
raise ValueError("No connected call; cannot wait for silence")
|
|
376
385
|
silence = float(data["silenceMs"]) / 1000
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
raise ValueError("Silence duration and timeout must be positive")
|
|
386
|
+
if not math.isfinite(silence) or silence <= 0:
|
|
387
|
+
raise ValueError("Silence duration must be positive")
|
|
380
388
|
# A fresh quiet window starts AFTER the application predicate
|
|
381
389
|
# succeeds; quiet heard before that does not satisfy this wait.
|
|
390
|
+
# The wait is bounded only by the test case's own timeout.
|
|
382
391
|
began = time.monotonic()
|
|
383
392
|
wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
|
|
384
|
-
|
|
393
|
+
live=True)
|
|
385
394
|
result["offset"] = call.recorder.offset
|
|
386
395
|
elif action == "position":
|
|
387
396
|
if not call:
|
|
@@ -10,6 +10,10 @@ MEDIA_RATE = 48000
|
|
|
10
10
|
PEAK_SAMPLES = MEDIA_RATE // 20
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
class RecordingError(RuntimeError):
|
|
14
|
+
"""SDK-authored capture diagnostics, safe to include in test results."""
|
|
15
|
+
|
|
16
|
+
|
|
13
17
|
class StreamingRecording:
|
|
14
18
|
def __init__(self, path):
|
|
15
19
|
self.path = path
|
|
@@ -85,7 +89,7 @@ class StreamingRecording:
|
|
|
85
89
|
self.encoder.kill()
|
|
86
90
|
self.writer.join(timeout=1)
|
|
87
91
|
if self.error:
|
|
88
|
-
raise
|
|
92
|
+
raise RecordingError(self.error)
|
|
89
93
|
return {
|
|
90
94
|
"duration": self.frames / MEDIA_RATE,
|
|
91
95
|
"bytes": os.path.getsize(self.path),
|
package/dist/phone.js
CHANGED
|
@@ -116,7 +116,9 @@ class PhoneSession {
|
|
|
116
116
|
return Promise.reject(new Error("Phone is closed"));
|
|
117
117
|
const id = ++this.nextId;
|
|
118
118
|
return new Promise((resolve, reject) => {
|
|
119
|
-
const timer =
|
|
119
|
+
const timer = Number.isFinite(timeout)
|
|
120
|
+
? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
|
|
121
|
+
: undefined;
|
|
120
122
|
this.pending.set(id, { resolve, reject, timer, position });
|
|
121
123
|
this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
|
|
122
124
|
});
|
|
@@ -128,15 +130,15 @@ class PhoneSession {
|
|
|
128
130
|
await this.request("open", { ...this.options, directory: this.directory }, 60_000);
|
|
129
131
|
return this;
|
|
130
132
|
}
|
|
133
|
+
// The predicate and the silence wait have no timeout of their own: a bound on
|
|
134
|
+
// the reply belongs inside the predicate (a poll with its own timeout), and the
|
|
135
|
+
// case timeout closes the phone, which aborts this wait.
|
|
131
136
|
async waitForResponse(options) {
|
|
132
|
-
const timeout = 30_000;
|
|
133
|
-
const deadline = performance.now() + timeout;
|
|
134
137
|
const controller = new AbortController();
|
|
135
138
|
const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
|
|
136
139
|
this.cancellation.signal.addEventListener("abort", cancel, { once: true });
|
|
137
140
|
if (this.closed)
|
|
138
141
|
cancel();
|
|
139
|
-
const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
|
|
140
142
|
const aborted = new Promise((_, reject) => {
|
|
141
143
|
const rejectAbort = () => reject(controller.signal.reason);
|
|
142
144
|
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
@@ -163,13 +165,11 @@ class PhoneSession {
|
|
|
163
165
|
}
|
|
164
166
|
}
|
|
165
167
|
controller.signal.throwIfAborted();
|
|
166
|
-
const
|
|
167
|
-
const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
|
|
168
|
+
const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
|
|
168
169
|
return { ...reply, response };
|
|
169
170
|
})]);
|
|
170
171
|
}
|
|
171
172
|
finally {
|
|
172
|
-
clearTimeout(timer);
|
|
173
173
|
this.cancellation.signal.removeEventListener("abort", cancel);
|
|
174
174
|
controller.abort();
|
|
175
175
|
branch.finish(false);
|
package/package.json
CHANGED
package/src/harness/phone.py
CHANGED
|
@@ -14,7 +14,7 @@ import time
|
|
|
14
14
|
import wave
|
|
15
15
|
import math
|
|
16
16
|
from array import array
|
|
17
|
-
from phone_recording import StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
17
|
+
from phone_recording import RecordingError, StreamingRecording, MEDIA_RATE, PEAK_SAMPLES
|
|
18
18
|
|
|
19
19
|
# PJSIP writes directly to fd 1. Keep the protocol on a private duplicate.
|
|
20
20
|
protocol = os.fdopen(os.dup(1), "w", buffering=1)
|
|
@@ -179,22 +179,29 @@ class Call(pj.Call):
|
|
|
179
179
|
result = {"event": "recording", "sessionId": self.session_id,
|
|
180
180
|
"startedAtMs": self.started_at_ms, "duration": 0, "bytes": 0,
|
|
181
181
|
"contentType": "audio/webm", "peaks": []}
|
|
182
|
+
errors = [self.capture_error] if self.capture_error else []
|
|
182
183
|
try:
|
|
183
184
|
self.clock.stopTransmit(self.recorder)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
185
|
+
except Exception as error:
|
|
186
|
+
# Native error text can contain SIP identities or credentials.
|
|
187
|
+
errors.append(f"Could not disconnect recording clock ({type(error).__name__})")
|
|
188
|
+
if self.media is not None:
|
|
189
|
+
try:
|
|
190
|
+
self.media.stopTransmit(self.recorder)
|
|
191
|
+
self.media.stopTransmit(self.recipient_meter)
|
|
192
|
+
except Exception:
|
|
193
|
+
pass # The remote peer may already have removed its port.
|
|
194
|
+
try:
|
|
195
|
+
# Always close the sink and reap the encoder, even if detach failed.
|
|
190
196
|
result.update(self.recorder.finish())
|
|
191
197
|
peaks = result["peaks"]
|
|
192
198
|
for side, track in self.recorder.tracks.items():
|
|
193
199
|
result[side] = (track + [0.0] * max(0, len(peaks) - len(track)))[:len(peaks)]
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
200
|
+
except Exception as error:
|
|
201
|
+
detail = str(error) if isinstance(error, RecordingError) else type(error).__name__
|
|
202
|
+
errors.append(f"Call recording could not be finalized: {detail}")
|
|
203
|
+
if errors:
|
|
204
|
+
result["error"] = "; ".join(errors)
|
|
198
205
|
protocol.write(json.dumps(result) + "\n")
|
|
199
206
|
|
|
200
207
|
|
|
@@ -271,12 +278,14 @@ def main():
|
|
|
271
278
|
else:
|
|
272
279
|
time.sleep(0.01)
|
|
273
280
|
|
|
274
|
-
def wait(predicate, timeout, live=False):
|
|
275
|
-
|
|
281
|
+
def wait(predicate, timeout=None, live=False):
|
|
282
|
+
# No timeout: run until satisfied, the call ends, or the SDK closes
|
|
283
|
+
# stdin (which happens at case timeout and cleanup).
|
|
284
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
276
285
|
while not predicate():
|
|
277
286
|
if live and call.disconnected:
|
|
278
287
|
raise ValueError(f"SIP call ended ({call.status})")
|
|
279
|
-
if time.monotonic() >= deadline:
|
|
288
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
280
289
|
raise ValueError("SIP operation timed out")
|
|
281
290
|
pump()
|
|
282
291
|
|
|
@@ -374,14 +383,14 @@ def main():
|
|
|
374
383
|
if not call or call.disconnected or call.media is None:
|
|
375
384
|
raise ValueError("No connected call; cannot wait for silence")
|
|
376
385
|
silence = float(data["silenceMs"]) / 1000
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
raise ValueError("Silence duration and timeout must be positive")
|
|
386
|
+
if not math.isfinite(silence) or silence <= 0:
|
|
387
|
+
raise ValueError("Silence duration must be positive")
|
|
380
388
|
# A fresh quiet window starts AFTER the application predicate
|
|
381
389
|
# succeeds; quiet heard before that does not satisfy this wait.
|
|
390
|
+
# The wait is bounded only by the test case's own timeout.
|
|
382
391
|
began = time.monotonic()
|
|
383
392
|
wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
|
|
384
|
-
|
|
393
|
+
live=True)
|
|
385
394
|
result["offset"] = call.recorder.offset
|
|
386
395
|
elif action == "position":
|
|
387
396
|
if not call:
|
|
@@ -10,6 +10,10 @@ MEDIA_RATE = 48000
|
|
|
10
10
|
PEAK_SAMPLES = MEDIA_RATE // 20
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
class RecordingError(RuntimeError):
|
|
14
|
+
"""SDK-authored capture diagnostics, safe to include in test results."""
|
|
15
|
+
|
|
16
|
+
|
|
13
17
|
class StreamingRecording:
|
|
14
18
|
def __init__(self, path):
|
|
15
19
|
self.path = path
|
|
@@ -85,7 +89,7 @@ class StreamingRecording:
|
|
|
85
89
|
self.encoder.kill()
|
|
86
90
|
self.writer.join(timeout=1)
|
|
87
91
|
if self.error:
|
|
88
|
-
raise
|
|
92
|
+
raise RecordingError(self.error)
|
|
89
93
|
return {
|
|
90
94
|
"duration": self.frames / MEDIA_RATE,
|
|
91
95
|
"bytes": os.path.getsize(self.path),
|
package/src/phone.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterAll, afterEach, beforeAll, expect,
|
|
1
|
+
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
|
|
2
2
|
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -16,7 +16,7 @@ beforeAll(async () => {
|
|
|
16
16
|
runtime,
|
|
17
17
|
`#!/usr/bin/env bun
|
|
18
18
|
import { createInterface } from "node:readline";
|
|
19
|
-
let connected = false, directory, sessionId, started, clockRate = 1;
|
|
19
|
+
let connected = false, directory, sessionId, started, recordingError, clockRate = 1;
|
|
20
20
|
const offset = () => (Date.now() - started) / 1000 * clockRate;
|
|
21
21
|
function finish() {
|
|
22
22
|
if (!sessionId) return;
|
|
@@ -24,7 +24,7 @@ function finish() {
|
|
|
24
24
|
const bytes = Buffer.alloc(128);
|
|
25
25
|
bytes.set([0x1a,0x45,0xdf,0xa3]);
|
|
26
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]}));
|
|
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], error:recordingError}));
|
|
28
28
|
sessionId = undefined;
|
|
29
29
|
}
|
|
30
30
|
createInterface({ input: process.stdin }).on("line", async line => {
|
|
@@ -34,6 +34,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
|
|
|
34
34
|
if (data.action === "open") directory = data.directory;
|
|
35
35
|
if (data.action === "call") {
|
|
36
36
|
sessionId = data.sessionId; started = Date.now();
|
|
37
|
+
recordingError = data.destination === "recording-failure"
|
|
38
|
+
? "Call recording could not be finalized: Phone recording encoder could not keep up with audio"
|
|
39
|
+
: undefined;
|
|
37
40
|
clockRate = data.destination === "clock-drift" ? 0.25 : 1;
|
|
38
41
|
console.log(JSON.stringify({event:"operation_started", id:data.id, offset:0}));
|
|
39
42
|
if (data.destination === "timeout") return;
|
|
@@ -55,10 +58,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
|
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
if (data.action === "wait_silence") {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: {offset:offset()};
|
|
61
|
+
if (data.timeoutMs !== undefined) throw new Error("wait_silence must not carry a timeout");
|
|
62
|
+
await Bun.sleep(data.silenceMs);
|
|
63
|
+
result = {offset:offset()};
|
|
62
64
|
}
|
|
63
65
|
if (data.action === "position") result = {offset:offset()};
|
|
64
66
|
console.log(JSON.stringify({ id: data.id, ...result }));
|
|
@@ -363,17 +365,17 @@ test("waits for the predicate then a fresh silence window inside one serialized
|
|
|
363
365
|
expect(recording!.duration - b.end).toBeGreaterThanOrEqual(.04);
|
|
364
366
|
});
|
|
365
367
|
|
|
366
|
-
test("predicate errors
|
|
368
|
+
test("predicate errors fail the say step without waiting for silence, and a hung predicate waits until the phone closes", async () => {
|
|
367
369
|
startRecording();
|
|
368
370
|
const phone = await scope.open(options);
|
|
369
371
|
await phone.call("123");
|
|
370
372
|
await expect(phone.say("Throw", { waitFor: () => { throw new Error("Application failed"); } })).rejects.toThrow("Application failed");
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
373
|
+
// No response-wait timeout of its own: only closing the phone (case timeout
|
|
374
|
+
// or cleanup) ends a predicate that never returns.
|
|
375
|
+
const hung = phone.say("Hang", { waitFor: () => new Promise(() => {}) }).catch(error => error);
|
|
376
|
+
await Bun.sleep(200);
|
|
377
|
+
await phone.close();
|
|
378
|
+
expect(String(await hung)).toContain("closed");
|
|
377
379
|
await scope.finish();
|
|
378
380
|
const events = stopRecording() as any[];
|
|
379
381
|
expect(events.slice(1).map(e => e.status)).toEqual(["failed", "failed"]);
|
|
@@ -413,10 +415,10 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
|
|
|
413
415
|
await writeFile(join(root, "spectest/index.ts"), `
|
|
414
416
|
import { defineEnvironment } from ${JSON.stringify(sdk)};
|
|
415
417
|
const env = defineEnvironment({ name: "phone-integration", services: {} });
|
|
416
|
-
const cases = ["success", "failure", "timeout"].map(outcome => env.test(outcome,
|
|
418
|
+
const cases = ["success", "failure", "timeout", "recording-failure"].map(outcome => env.test(outcome,
|
|
417
419
|
{ timeoutMs: outcome === "timeout" ? 350 : 2000 }, async ctx => {
|
|
418
420
|
const phone = await ctx.phone(${JSON.stringify(options)});
|
|
419
|
-
await phone.call("123");
|
|
421
|
+
await phone.call(outcome === "recording-failure" ? outcome : "123");
|
|
420
422
|
await phone.say("One. Two.");
|
|
421
423
|
await new Promise(r => setTimeout(r, 30));
|
|
422
424
|
if (outcome === "failure") throw new Error("application assertion failed");
|
|
@@ -429,7 +431,7 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
|
|
|
429
431
|
import { strict as assert } from "node:assert";
|
|
430
432
|
import { METHODS } from ${JSON.stringify(daemon)};
|
|
431
433
|
const loaded = await METHODS.load({});
|
|
432
|
-
assert.equal(loaded.cases.length,
|
|
434
|
+
assert.equal(loaded.cases.length, 4);
|
|
433
435
|
for (const [index, test] of loaded.cases.entries()) {
|
|
434
436
|
const result = await METHODS.run({caseId:test.id});
|
|
435
437
|
assert.equal(result.status, index === 0 ? "passed" : "failed", JSON.stringify(result));
|
|
@@ -437,6 +439,13 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
|
|
|
437
439
|
if (index === 2) assert.match(result.error.message, /timed out/);
|
|
438
440
|
assert.equal(result.phoneRecordings.length, 1);
|
|
439
441
|
const recording = result.phoneRecordings[0];
|
|
442
|
+
if (index === 3) {
|
|
443
|
+
const message = "Call recording could not be finalized: Phone recording encoder could not keep up with audio";
|
|
444
|
+
assert.equal(recording.error, message);
|
|
445
|
+
assert.equal(result.error.message, message);
|
|
446
|
+
await assert.rejects(METHODS.phoneRecordingChunk({caseId:recording.id}), /Unknown/);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
440
449
|
assert.equal(recording.error, undefined);
|
|
441
450
|
assert.ok(recording.duration > 0);
|
|
442
451
|
assert.deepEqual(recording.callerPeaks, [.2,0]);
|
package/src/phone.ts
CHANGED
|
@@ -83,7 +83,7 @@ export async function releasePhoneRecording(id: string) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
class PhoneSession implements Phone {
|
|
86
|
-
private pending = new Map<number, { resolve: (r: Reply) => void; reject: (e: Error) => void; timer
|
|
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
87
|
private nextId = 0;
|
|
88
88
|
private closed = false;
|
|
89
89
|
private failure?: Error;
|
|
@@ -138,7 +138,9 @@ class PhoneSession implements Phone {
|
|
|
138
138
|
if (this.closed) return Promise.reject(new Error("Phone is closed"));
|
|
139
139
|
const id = ++this.nextId;
|
|
140
140
|
return new Promise((resolve, reject) => {
|
|
141
|
-
const timer =
|
|
141
|
+
const timer = Number.isFinite(timeout)
|
|
142
|
+
? setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout)
|
|
143
|
+
: undefined;
|
|
142
144
|
this.pending.set(id, { resolve, reject, timer, position });
|
|
143
145
|
this.child.stdin.write(JSON.stringify({ id, action, ...data }) + "\n");
|
|
144
146
|
});
|
|
@@ -149,14 +151,14 @@ class PhoneSession implements Phone {
|
|
|
149
151
|
await this.request("open", { ...this.options, directory: this.directory }, 60_000);
|
|
150
152
|
return this;
|
|
151
153
|
}
|
|
154
|
+
// The predicate and the silence wait have no timeout of their own: a bound on
|
|
155
|
+
// the reply belongs inside the predicate (a poll with its own timeout), and the
|
|
156
|
+
// case timeout closes the phone, which aborts this wait.
|
|
152
157
|
private async waitForResponse(options: CallOptions): Promise<Reply & { response?: string }> {
|
|
153
|
-
const timeout = 30_000;
|
|
154
|
-
const deadline = performance.now() + timeout;
|
|
155
158
|
const controller = new AbortController();
|
|
156
159
|
const cancel = () => controller.abort(this.failure ?? new Error("Phone closed"));
|
|
157
160
|
this.cancellation.signal.addEventListener("abort", cancel, { once: true });
|
|
158
161
|
if (this.closed) cancel();
|
|
159
|
-
const timer = setTimeout(() => controller.abort(new Error(`Phone response wait timed out after ${timeout}ms`)), timeout);
|
|
160
162
|
const aborted = new Promise<never>((_, reject) => {
|
|
161
163
|
const rejectAbort = () => reject(controller.signal.reason);
|
|
162
164
|
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
@@ -181,12 +183,10 @@ class PhoneSession implements Phone {
|
|
|
181
183
|
}
|
|
182
184
|
}
|
|
183
185
|
controller.signal.throwIfAborted();
|
|
184
|
-
const
|
|
185
|
-
const reply = await this.request("wait_silence", { silenceMs: 700, timeoutMs: remaining }, remaining + 1000);
|
|
186
|
+
const reply = await this.request("wait_silence", { silenceMs: 700 }, Infinity);
|
|
186
187
|
return { ...reply, response };
|
|
187
188
|
})]);
|
|
188
189
|
} finally {
|
|
189
|
-
clearTimeout(timer);
|
|
190
190
|
this.cancellation.signal.removeEventListener("abort", cancel);
|
|
191
191
|
controller.abort();
|
|
192
192
|
branch.finish(false);
|