@specific.dev/spectest 0.131.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.
@@ -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
- if self.media is not None:
185
- try:
186
- self.media.stopTransmit(self.recorder)
187
- self.media.stopTransmit(self.recipient_meter)
188
- except Exception:
189
- pass # The remote peer may already have removed its port.
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
- if self.capture_error:
195
- result["error"] = self.capture_error
196
- except Exception:
197
- result["error"] = "Call recording could not be finalized"
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
 
@@ -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 RuntimeError(self.error)
92
+ raise RecordingError(self.error)
89
93
  return {
90
94
  "duration": self.frames / MEDIA_RATE,
91
95
  "bytes": os.path.getsize(self.path),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.0",
3
+ "version": "0.131.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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
- if self.media is not None:
185
- try:
186
- self.media.stopTransmit(self.recorder)
187
- self.media.stopTransmit(self.recipient_meter)
188
- except Exception:
189
- pass # The remote peer may already have removed its port.
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
- if self.capture_error:
195
- result["error"] = self.capture_error
196
- except Exception:
197
- result["error"] = "Call recording could not be finalized"
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
 
@@ -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 RuntimeError(self.error)
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
@@ -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;
@@ -412,10 +415,10 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
412
415
  await writeFile(join(root, "spectest/index.ts"), `
413
416
  import { defineEnvironment } from ${JSON.stringify(sdk)};
414
417
  const env = defineEnvironment({ name: "phone-integration", services: {} });
415
- const cases = ["success", "failure", "timeout"].map(outcome => env.test(outcome,
418
+ const cases = ["success", "failure", "timeout", "recording-failure"].map(outcome => env.test(outcome,
416
419
  { timeoutMs: outcome === "timeout" ? 350 : 2000 }, async ctx => {
417
420
  const phone = await ctx.phone(${JSON.stringify(options)});
418
- await phone.call("123");
421
+ await phone.call(outcome === "recording-failure" ? outcome : "123");
419
422
  await phone.say("One. Two.");
420
423
  await new Promise(r => setTimeout(r, 30));
421
424
  if (outcome === "failure") throw new Error("application assertion failed");
@@ -428,7 +431,7 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
428
431
  import { strict as assert } from "node:assert";
429
432
  import { METHODS } from ${JSON.stringify(daemon)};
430
433
  const loaded = await METHODS.load({});
431
- assert.equal(loaded.cases.length, 3);
434
+ assert.equal(loaded.cases.length, 4);
432
435
  for (const [index, test] of loaded.cases.entries()) {
433
436
  const result = await METHODS.run({caseId:test.id});
434
437
  assert.equal(result.status, index === 0 ? "passed" : "failed", JSON.stringify(result));
@@ -436,6 +439,13 @@ test("real harness exposes ctx.phone, seals recordings on every outcome and serv
436
439
  if (index === 2) assert.match(result.error.message, /timed out/);
437
440
  assert.equal(result.phoneRecordings.length, 1);
438
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
+ }
439
449
  assert.equal(recording.error, undefined);
440
450
  assert.ok(recording.duration > 0);
441
451
  assert.deepEqual(recording.callerPeaks, [.2,0]);