@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 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 quiet
482
- period (700 ms), poll interval (100 ms), and wait timeout (30 seconds) are
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.
@@ -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
- deadline = time.monotonic() + timeout
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
- timeout = float(data["timeoutMs"]) / 1000
378
- if not math.isfinite(silence) or not math.isfinite(timeout) or silence <= 0 or timeout <= 0:
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
- timeout, live=True)
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 = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
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 remaining = Math.max(1, deadline - performance.now());
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.130.0",
3
+ "version": "0.131.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- deadline = time.monotonic() + timeout
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
- timeout = float(data["timeoutMs"]) / 1000
378
- if not math.isfinite(silence) or not math.isfinite(timeout) or silence <= 0 or timeout <= 0:
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
- timeout, live=True)
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, spyOn, test } from "bun:test";
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
- await Bun.sleep(Math.min(data.silenceMs, data.timeoutMs));
59
- result = data.silenceMs > data.timeoutMs
60
- ? {error:"SIP operation timed out"}
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 and hung predicates fail the say step without waiting for silence", async () => {
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
- 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(); }
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: ReturnType<typeof setTimeout>; position?: (event: string, offset: number) => void }>();
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 = setTimeout(() => { this.fail(new Error(`Phone ${action} timed out after ${timeout}ms`)); void this.close(); }, timeout);
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 remaining = Math.max(1, deadline - performance.now());
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);