@specific.dev/spectest 0.130.0 → 0.131.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 +5 -2
- package/dist/harness/phone.py +9 -7
- package/dist/phone.js +7 -7
- package/package.json +1 -1
- package/src/harness/phone.py +9 -7
- package/src/phone.test.ts +11 -12
- 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
|
@@ -271,12 +271,14 @@ def main():
|
|
|
271
271
|
else:
|
|
272
272
|
time.sleep(0.01)
|
|
273
273
|
|
|
274
|
-
def wait(predicate, timeout, live=False):
|
|
275
|
-
|
|
274
|
+
def wait(predicate, timeout=None, live=False):
|
|
275
|
+
# No timeout: run until satisfied, the call ends, or the SDK closes
|
|
276
|
+
# stdin (which happens at case timeout and cleanup).
|
|
277
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
276
278
|
while not predicate():
|
|
277
279
|
if live and call.disconnected:
|
|
278
280
|
raise ValueError(f"SIP call ended ({call.status})")
|
|
279
|
-
if time.monotonic() >= deadline:
|
|
281
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
280
282
|
raise ValueError("SIP operation timed out")
|
|
281
283
|
pump()
|
|
282
284
|
|
|
@@ -374,14 +376,14 @@ def main():
|
|
|
374
376
|
if not call or call.disconnected or call.media is None:
|
|
375
377
|
raise ValueError("No connected call; cannot wait for silence")
|
|
376
378
|
silence = float(data["silenceMs"]) / 1000
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
raise ValueError("Silence duration and timeout must be positive")
|
|
379
|
+
if not math.isfinite(silence) or silence <= 0:
|
|
380
|
+
raise ValueError("Silence duration must be positive")
|
|
380
381
|
# A fresh quiet window starts AFTER the application predicate
|
|
381
382
|
# succeeds; quiet heard before that does not satisfy this wait.
|
|
383
|
+
# The wait is bounded only by the test case's own timeout.
|
|
382
384
|
began = time.monotonic()
|
|
383
385
|
wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
|
|
384
|
-
|
|
386
|
+
live=True)
|
|
385
387
|
result["offset"] = call.recorder.offset
|
|
386
388
|
elif action == "position":
|
|
387
389
|
if not call:
|
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
|
@@ -271,12 +271,14 @@ def main():
|
|
|
271
271
|
else:
|
|
272
272
|
time.sleep(0.01)
|
|
273
273
|
|
|
274
|
-
def wait(predicate, timeout, live=False):
|
|
275
|
-
|
|
274
|
+
def wait(predicate, timeout=None, live=False):
|
|
275
|
+
# No timeout: run until satisfied, the call ends, or the SDK closes
|
|
276
|
+
# stdin (which happens at case timeout and cleanup).
|
|
277
|
+
deadline = None if timeout is None else time.monotonic() + timeout
|
|
276
278
|
while not predicate():
|
|
277
279
|
if live and call.disconnected:
|
|
278
280
|
raise ValueError(f"SIP call ended ({call.status})")
|
|
279
|
-
if time.monotonic() >= deadline:
|
|
281
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
280
282
|
raise ValueError("SIP operation timed out")
|
|
281
283
|
pump()
|
|
282
284
|
|
|
@@ -374,14 +376,14 @@ def main():
|
|
|
374
376
|
if not call or call.disconnected or call.media is None:
|
|
375
377
|
raise ValueError("No connected call; cannot wait for silence")
|
|
376
378
|
silence = float(data["silenceMs"]) / 1000
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
raise ValueError("Silence duration and timeout must be positive")
|
|
379
|
+
if not math.isfinite(silence) or silence <= 0:
|
|
380
|
+
raise ValueError("Silence duration must be positive")
|
|
380
381
|
# A fresh quiet window starts AFTER the application predicate
|
|
381
382
|
# succeeds; quiet heard before that does not satisfy this wait.
|
|
383
|
+
# The wait is bounded only by the test case's own timeout.
|
|
382
384
|
began = time.monotonic()
|
|
383
385
|
wait(lambda: time.monotonic() - max(began, call.incoming.last_voice) >= silence,
|
|
384
|
-
|
|
386
|
+
live=True)
|
|
385
387
|
result["offset"] = call.recorder.offset
|
|
386
388
|
elif action == "position":
|
|
387
389
|
if not call:
|
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";
|
|
@@ -55,10 +55,9 @@ createInterface({ input: process.stdin }).on("line", async line => {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
if (data.action === "wait_silence") {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
: {offset:offset()};
|
|
58
|
+
if (data.timeoutMs !== undefined) throw new Error("wait_silence must not carry a timeout");
|
|
59
|
+
await Bun.sleep(data.silenceMs);
|
|
60
|
+
result = {offset:offset()};
|
|
62
61
|
}
|
|
63
62
|
if (data.action === "position") result = {offset:offset()};
|
|
64
63
|
console.log(JSON.stringify({ id: data.id, ...result }));
|
|
@@ -363,17 +362,17 @@ test("waits for the predicate then a fresh silence window inside one serialized
|
|
|
363
362
|
expect(recording!.duration - b.end).toBeGreaterThanOrEqual(.04);
|
|
364
363
|
});
|
|
365
364
|
|
|
366
|
-
test("predicate errors
|
|
365
|
+
test("predicate errors fail the say step without waiting for silence, and a hung predicate waits until the phone closes", async () => {
|
|
367
366
|
startRecording();
|
|
368
367
|
const phone = await scope.open(options);
|
|
369
368
|
await phone.call("123");
|
|
370
369
|
await expect(phone.say("Throw", { waitFor: () => { throw new Error("Application failed"); } })).rejects.toThrow("Application failed");
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
370
|
+
// No response-wait timeout of its own: only closing the phone (case timeout
|
|
371
|
+
// or cleanup) ends a predicate that never returns.
|
|
372
|
+
const hung = phone.say("Hang", { waitFor: () => new Promise(() => {}) }).catch(error => error);
|
|
373
|
+
await Bun.sleep(200);
|
|
374
|
+
await phone.close();
|
|
375
|
+
expect(String(await hung)).toContain("closed");
|
|
377
376
|
await scope.finish();
|
|
378
377
|
const events = stopRecording() as any[];
|
|
379
378
|
expect(events.slice(1).map(e => e.status)).toEqual(["failed", "failed"]);
|
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);
|