@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.
- package/dist/components/android.d.ts +2 -0
- package/dist/components/android.js +2 -1
- package/dist/components/apple.d.ts +1 -0
- package/dist/components/emulate/service.d.ts +1 -0
- package/dist/components/emulate/service.js +2 -1
- package/dist/components/github.d.ts +1 -0
- package/dist/components/google.d.ts +1 -0
- package/dist/components/microsoft.d.ts +1 -0
- package/dist/components/okta.d.ts +1 -0
- package/dist/components/s3.d.ts +1 -0
- package/dist/components/s3.js +2 -1
- package/dist/harness/phone.py +18 -11
- package/dist/harness/phone_recording.py +5 -1
- package/package.json +1 -1
- package/src/components/android.test.ts +14 -0
- package/src/components/android.ts +2 -1
- package/src/components/build-reach.test.ts +48 -0
- package/src/components/electron.test.ts +21 -1
- package/src/components/emulate/service.ts +2 -1
- package/src/components/expo.test.ts +16 -0
- package/src/components/s3.ts +2 -1
- package/src/components/supabase-functions-reach.test.ts +18 -0
- package/src/harness/phone.py +18 -11
- package/src/harness/phone_recording.py +5 -1
- package/src/phone.test.ts +15 -5
|
@@ -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
|
{
|
|
@@ -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
|
|
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] }) },
|
package/dist/components/s3.d.ts
CHANGED
package/dist/components/s3.js
CHANGED
|
@@ -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
|
-
|
|
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
|
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
|
|
|
@@ -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/package.json
CHANGED
|
@@ -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
|
|
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());
|
package/src/components/s3.ts
CHANGED
|
@@ -108,7 +108,8 @@ export function s3(opts: S3Options = {}) {
|
|
|
108
108
|
const region = opts.region ?? "us-east-1";
|
|
109
109
|
|
|
110
110
|
return {
|
|
111
|
-
|
|
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;
|
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
|
|
|
@@ -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
|
@@ -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,
|
|
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]);
|