@specific.dev/spectest 0.131.0 → 0.131.2

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.
@@ -42,9 +42,11 @@ export declare function android(options: AndroidOptions): import("../index.js").
42
42
  type: "registry";
43
43
  reference: string;
44
44
  content?: undefined;
45
+ exclude?: undefined;
45
46
  } | {
46
47
  type: "dockerfile";
47
48
  content: string;
49
+ exclude: string[];
48
50
  reference?: undefined;
49
51
  };
50
52
  command: string;
@@ -70,9 +70,10 @@ export function android(options) {
70
70
  },
71
71
  },
72
72
  tools: {
73
+ // Tools are SDK-owned; the APK is a separate input below.
73
74
  image: options.toolsImage
74
75
  ? { type: "registry", reference: options.toolsImage }
75
- : { type: "dockerfile", content: ANDROID_TOOLS_DOCKERFILE },
76
+ : { type: "dockerfile", content: ANDROID_TOOLS_DOCKERFILE, exclude: ["**"] },
76
77
  command: "node /spectest-android.mjs",
77
78
  files: [
78
79
  {
@@ -28,6 +28,7 @@ export declare function apple(opts?: AppleOptions): {
28
28
  image: {
29
29
  type: "dockerfile";
30
30
  content: string;
31
+ exclude: string[];
31
32
  };
32
33
  command: string;
33
34
  files: {
@@ -89,6 +89,7 @@ export declare function emulatorService(spec: ProviderSpec): {
89
89
  image: {
90
90
  type: "dockerfile";
91
91
  content: string;
92
+ exclude: string[];
92
93
  };
93
94
  command: string;
94
95
  files: {
@@ -98,7 +98,8 @@ RUN echo '{"name":"spectest-provider","private":true}' > package.json \\
98
98
  * entry, and a provider can claim four names. */
99
99
  export function emulatorService(spec) {
100
100
  const service = {
101
- image: { type: "dockerfile", content: DOCKERFILE },
101
+ // The image installs packages; its entry script is injected via files.
102
+ image: { type: "dockerfile", content: DOCKERFILE, exclude: ["**"] },
102
103
  command: "bun /srv/auth.mjs",
103
104
  files: [{ path: "/srv/auth.mjs", content: ENTRY_SCRIPT }],
104
105
  env: { SPECTEST_AUTH_CONFIG: JSON.stringify({ port: PORT, providers: [spec] }) },
@@ -33,6 +33,7 @@ export declare function github(opts?: GithubOptions): {
33
33
  image: {
34
34
  type: "dockerfile";
35
35
  content: string;
36
+ exclude: string[];
36
37
  };
37
38
  command: string;
38
39
  files: {
@@ -38,6 +38,7 @@ export declare function google(opts?: GoogleOptions): {
38
38
  image: {
39
39
  type: "dockerfile";
40
40
  content: string;
41
+ exclude: string[];
41
42
  };
42
43
  command: string;
43
44
  files: {
@@ -36,6 +36,7 @@ export declare function microsoft(opts?: MicrosoftOptions): {
36
36
  image: {
37
37
  type: "dockerfile";
38
38
  content: string;
39
+ exclude: string[];
39
40
  };
40
41
  command: string;
41
42
  files: {
@@ -32,6 +32,7 @@ export declare function okta(opts: OktaOptions): {
32
32
  image: {
33
33
  type: "dockerfile";
34
34
  content: string;
35
+ exclude: string[];
35
36
  };
36
37
  command: string;
37
38
  files: {
@@ -91,6 +91,7 @@ export declare function s3(opts?: S3Options): {
91
91
  image: {
92
92
  type: "dockerfile";
93
93
  content: string;
94
+ exclude: string[];
94
95
  };
95
96
  ports: number[];
96
97
  };
@@ -61,7 +61,8 @@ export function s3(opts = {}) {
61
61
  const secretAccessKey = opts.secretAccessKey ?? "s3";
62
62
  const region = opts.region ?? "us-east-1";
63
63
  return {
64
- image: { type: "dockerfile", content: S3_DOCKERFILE },
64
+ // All sources are embedded above; no project file is a build input.
65
+ image: { type: "dockerfile", content: S3_DOCKERFILE, exclude: ["**"] },
65
66
  ports: [S3_PORT],
66
67
  ...(opts.env ? { env: opts.env } : {}),
67
68
  ...(opts.hosts
@@ -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.2",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -3,6 +3,8 @@ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { android } from "./android.js";
6
+ import { serviceDockerignore } from "../harness/build-context.js";
7
+ import { compactReach, projectReach, reachContains } from "../harness/reach.js";
6
8
 
7
9
  test("Android expands into two VM services with an APK input and private Appium/ADB addresses", async () => {
8
10
  const dir = mkdtempSync(join(tmpdir(), "spectest-android-")),
@@ -45,6 +47,18 @@ test("Android expands into two VM services with an APK input and private Appium/
45
47
  expect(tools.env?.ANDROID_SHOW_TOUCHES).toBe("1");
46
48
  expect(tools.env?.ANDROID_CA_PATH).toBe(process.env.SPECTEST_CA_PATH ?? "/etc/spectest/ca.crt");
47
49
  expect(tools.inputs).toContain("app.apk");
50
+ const projectFiles = ["app.apk", "terraform/budgets.tf", "k8s/nodepools/README.md"];
51
+ const sources = [{
52
+ service: "phone",
53
+ context: tools.image.context ?? ".",
54
+ ignore: serviceDockerignore(null, tools.image.exclude),
55
+ inputs: tools.inputs,
56
+ }];
57
+ const reached = projectReach(projectFiles, sources);
58
+ expect(reached).toEqual(["app.apk"]);
59
+ const rules = { version: 1, sources, prefixes: compactReach(projectFiles, reached) };
60
+ expect(reachContains(rules, "app.apk")).toBe(true);
61
+ expect(reachContains(rules, "k8s/new-nodepool.yaml")).toBe(false);
48
62
  expect(tools.volumes).toContainEqual({
49
63
  source: apk,
50
64
  target: "/spectest/app.apk",
@@ -101,9 +101,10 @@ export function android(options: AndroidOptions) {
101
101
  },
102
102
  },
103
103
  tools: {
104
+ // Tools are SDK-owned; the APK is a separate input below.
104
105
  image: options.toolsImage
105
106
  ? { type: "registry" as const, reference: options.toolsImage }
106
- : { type: "dockerfile" as const, content: ANDROID_TOOLS_DOCKERFILE },
107
+ : { type: "dockerfile" as const, content: ANDROID_TOOLS_DOCKERFILE, exclude: ["**"] },
107
108
  command: "node /spectest-android.mjs",
108
109
  files: [
109
110
  {
@@ -0,0 +1,48 @@
1
+ import { expect, test } from "bun:test";
2
+ import { defineEnvironment } from "../index.js";
3
+ import { serviceDockerignore } from "../harness/build-context.js";
4
+ import { compactReach, projectReach, reachContains } from "../harness/reach.js";
5
+ import { s3 } from "./s3.js";
6
+ import { google } from "./google.js";
7
+ import { github } from "./github.js";
8
+ import { apple } from "./apple.js";
9
+ import { microsoft } from "./microsoft.js";
10
+ import { okta } from "./okta.js";
11
+
12
+ const unrelatedFiles = [
13
+ "terraform/budgets.tf",
14
+ "k8s/nodepools/README.md",
15
+ "docs/guide.md",
16
+ "packages/app/index.ts",
17
+ ];
18
+
19
+ for (const [name, component] of Object.entries({
20
+ s3: s3(),
21
+ google: google(),
22
+ github: github(),
23
+ apple: apple(),
24
+ microsoft: microsoft(),
25
+ okta: okta({ domain: "example.okta.com" }),
26
+ })) {
27
+ test(`${name}'s SDK-owned build does not make project files reachable`, () => {
28
+ const services = defineEnvironment({ name, services: { component } }).config.services;
29
+ for (const projectIgnore of [null, "**\n!terraform/**\n!packages/**"]) {
30
+ const sources = Object.entries(services).map(([service, svc]) => {
31
+ expect(svc.image.type).toBe("dockerfile");
32
+ if (svc.image.type !== "dockerfile") throw new Error("expected a built image");
33
+ return {
34
+ service,
35
+ context: svc.image.context ?? ".",
36
+ // A project's negations must not re-include files in this build.
37
+ ignore: serviceDockerignore(projectIgnore, svc.image.exclude),
38
+ };
39
+ });
40
+ const files = projectReach(unrelatedFiles, sources);
41
+ expect(files).toEqual([]);
42
+ const rules = { version: 1, sources, prefixes: compactReach(unrelatedFiles, files) };
43
+ for (const file of [...unrelatedFiles, "k8s/new-nodepool.yaml"]) {
44
+ expect(reachContains(rules, file)).toBe(false);
45
+ }
46
+ }
47
+ });
48
+ }
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { join, relative } from 'node:path';
5
5
  import { electron } from './electron.js';
6
6
  import { desktopApp, isDesktopApp } from '../desktop.js';
7
+ import { serviceDockerignore } from '../harness/build-context.js';
8
+ import { projectReach, reachContains } from '../harness/reach.js';
7
9
 
8
10
  function fixture(run: (directory: string) => void) {
9
11
  const directory = mkdtempSync(join(tmpdir(), 'spectest-electron-'));
@@ -57,6 +59,24 @@ describe('Electron component in a monorepo', () => {
57
59
  expect(content).toContain('RUN cd apps/desktop && npm run build\n');
58
60
  expect(service.env?.SPECTEST_ELECTRON_APP_DIR).toBe('/app/apps/desktop');
59
61
  }));
62
+ test('the default app context excludes siblings while an explicit monorepo context honors ignore rules', () => monorepo(root => {
63
+ writeFileSync(join(root, 'apps', 'desktop', 'package.json'), JSON.stringify({ devDependencies: { electron: '44.0.0' } }));
64
+ for (const context of [undefined, root]) {
65
+ const image = electron({ appDir: join(root, 'apps/desktop'), context }).image;
66
+ // Fixtures use absolute paths; the guest receives project-relative paths.
67
+ const sources = [{
68
+ context: relative(root, image.context) || '.',
69
+ ignore: serviceDockerignore('docs\nterraform', image.exclude),
70
+ }];
71
+ const files = ['apps/desktop/main.ts', 'apps/desktop/node_modules/x/index.js', 'packages/shared/index.ts', 'docs/guide.md', 'terraform/budgets.tf'];
72
+ expect(projectReach(files, sources)).toEqual(context
73
+ ? ['apps/desktop/main.ts', 'packages/shared/index.ts']
74
+ : ['apps/desktop/main.ts']);
75
+ const rules = { version: 1, sources, prefixes: [] };
76
+ expect(reachContains(rules, 'apps/desktop/new-window.ts')).toBe(true);
77
+ expect(reachContains(rules, 'docs/new-guide.md')).toBe(false);
78
+ }
79
+ }));
60
80
  test('accepts electron declared at the context root only', () => monorepo(root => {
61
81
  expect(() => electron({ appDir: join(root, 'apps/desktop'), context: root })).not.toThrow();
62
82
  writeFileSync(join(root, 'package.json'), JSON.stringify({ workspaces: ['apps/*'] }));
@@ -174,7 +174,8 @@ RUN echo '{"name":"spectest-provider","private":true}' > package.json \\
174
174
  * entry, and a provider can claim four names. */
175
175
  export function emulatorService(spec: ProviderSpec) {
176
176
  const service = {
177
- image: { type: "dockerfile" as const, content: DOCKERFILE },
177
+ // The image installs packages; its entry script is injected via files.
178
+ image: { type: "dockerfile" as const, content: DOCKERFILE, exclude: ["**"] },
178
179
  command: "bun /srv/auth.mjs",
179
180
  files: [{ path: "/srv/auth.mjs", content: ENTRY_SCRIPT }],
180
181
  env: { SPECTEST_AUTH_CONFIG: JSON.stringify({ port: PORT, providers: [spec] }) },
@@ -11,6 +11,8 @@ import { join } from "node:path";
11
11
 
12
12
  import { expo, nodeMajorFromRange } from "./expo.js";
13
13
  import type { ServiceConfig } from "../index.js";
14
+ import { serviceDockerignore } from "../harness/build-context.js";
15
+ import { projectReach, reachContains } from "../harness/reach.js";
14
16
 
15
17
  let root = "";
16
18
  const prevWorkspace = process.env.SPECTEST_WORKSPACE;
@@ -53,6 +55,20 @@ function lineOf(text: string, needle: string): number {
53
55
  }
54
56
 
55
57
  describe("expo() Dockerfile caching", () => {
58
+ test("the app context excludes sibling infrastructure and docs but includes new app code", () => {
59
+ app({ "package.json": pkg(), "App.tsx": "export default 1" }, "apps/mobile");
60
+ const image = expo({ appDir: "apps/mobile" }).image;
61
+ if (image.type !== "dockerfile") throw new Error("expected a built image");
62
+ const sources = [{ context: image.context, ignore: serviceDockerignore(null, image.exclude) }];
63
+ expect(projectReach([
64
+ "apps/mobile/package.json", "apps/mobile/App.tsx", "apps/mobile/node_modules/x/index.js",
65
+ "apps/mobile/dist/index.html", "terraform/budgets.tf", "docs/guide.md", "apps/other/index.ts",
66
+ ], sources)).toEqual(["apps/mobile/App.tsx", "apps/mobile/package.json"]);
67
+ const rules = { version: 1, sources, prefixes: [] };
68
+ expect(reachContains(rules, "apps/mobile/new-screen.tsx")).toBe(true);
69
+ expect(reachContains(rules, "k8s/new-nodepool.yaml")).toBe(false);
70
+ });
71
+
56
72
  test("installs from the manifests before it copies the source", () => {
57
73
  app({ "package.json": pkg(), "package-lock.json": "{}", "App.js": "x" });
58
74
  const df = dockerfile(expo());
@@ -108,7 +108,8 @@ export function s3(opts: S3Options = {}) {
108
108
  const region = opts.region ?? "us-east-1";
109
109
 
110
110
  return {
111
- image: { type: "dockerfile", content: S3_DOCKERFILE },
111
+ // All sources are embedded above; no project file is a build input.
112
+ image: { type: "dockerfile", content: S3_DOCKERFILE, exclude: ["**"] },
112
113
  ports: [S3_PORT],
113
114
  ...(opts.env ? { env: opts.env } : {}),
114
115
  ...(opts.hosts
@@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
6
6
  import { excludeAllBut, functionsReach, importMapLocalTargets, importSpecifiers } from "./supabase-functions-reach.js";
7
+ import { serviceDockerignore } from "../harness/build-context.js";
8
+ import { projectReach, reachContains } from "../harness/reach.js";
7
9
 
8
10
  let root: string;
9
11
 
@@ -82,6 +84,22 @@ describe("supabase() wiring", () => {
82
84
  expect(fn.image.exclude?.at(-1)).toBe("**/.venv/**");
83
85
  expect(fn.volumes?.[0]).toMatchObject({ source: root, reachExempt: true });
84
86
  expect((parts.db as { inputs?: readonly string[] }).inputs).toEqual(["supabase"]);
87
+ const sources = [
88
+ { context: fn.image.context, ignore: serviceDockerignore(null, fn.image.exclude) },
89
+ { inputs: [...(parts.db as { inputs: string[] }).inputs] },
90
+ ];
91
+ expect(projectReach([
92
+ "supabase/config.toml", "supabase/migrations/001.sql", "supabase/functions/hello/index.ts",
93
+ "packages/shared/src/greet.ts", "lib/db/client.ts", "packages/unrelated/index.ts",
94
+ "docs/guide.md", "site/app/page.tsx", "terraform/budgets.tf",
95
+ ], sources)).toEqual([
96
+ "lib/db/client.ts", "packages/shared/src/greet.ts", "supabase/config.toml",
97
+ "supabase/functions/hello/index.ts", "supabase/migrations/001.sql",
98
+ ]);
99
+ const rules = { version: 1, sources, prefixes: [] };
100
+ expect(reachContains(rules, "packages/shared/src/new-helper.ts")).toBe(true);
101
+ expect(reachContains(rules, "supabase/migrations/002.sql")).toBe(true);
102
+ expect(reachContains(rules, "k8s/new-nodepool.yaml")).toBe(false);
85
103
  } finally {
86
104
  if (prev === undefined) delete process.env.SPECTEST_WORKSPACE;
87
105
  else process.env.SPECTEST_WORKSPACE = prev;
@@ -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]);