@app-vitals/agent-runtime 0.0.0-stage → 0.1.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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
@@ -0,0 +1,669 @@
1
+ /**
2
+ * Integration tests for agent/src/runtime/voice.ts
3
+ *
4
+ * Strategy: use injected fetchFn for Groq/ElevenLabs HTTP calls (no global.* mutation
5
+ * per test-system.md). Use an injected fetchFn for the whisper-svc HTTP client tests.
6
+ * Use real readFileSync so file-size checks work against actual temp files.
7
+ * Use real file I/O for tmp audio files.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
+ import type { ChildProcess, SpawnOptions } from "node:child_process";
12
+ import { EventEmitter } from "node:events";
13
+ import { existsSync, statSync, unlinkSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import type { VoiceConfig, WhisperClientFn } from "./voice.ts";
17
+ import {
18
+ makeWhisperSvcClient,
19
+ synthesizeSpeech,
20
+ transcribeAudio,
21
+ } from "./voice.ts";
22
+
23
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
24
+
25
+ function makeTempAudioFile(sizeBytes = 1000): string {
26
+ const p = join(tmpdir(), `voice-test-${Date.now()}.webm`);
27
+ writeFileSync(p, Buffer.alloc(sizeBytes));
28
+ return p;
29
+ }
30
+
31
+ const testConfig: VoiceConfig = {
32
+ groqApiKey: "test-groq-key",
33
+ elevenLabsApiKey: "test-eleven-key",
34
+ voiceId: "test-voice-id",
35
+ };
36
+
37
+ // ─── transcribeAudio ──────────────────────────────────────────────────────────
38
+
39
+ describe("transcribeAudio", () => {
40
+ let tmpFile: string;
41
+
42
+ beforeEach(() => {
43
+ tmpFile = makeTempAudioFile(1000);
44
+ });
45
+
46
+ afterEach(() => {
47
+ if (tmpFile && existsSync(tmpFile)) unlinkSync(tmpFile);
48
+ });
49
+
50
+ test("skips Groq and returns null when groqApiKey is not set and no whisper client", async () => {
51
+ const result = await transcribeAudio(tmpFile, {});
52
+ expect(result).toBeNull();
53
+ });
54
+
55
+ test("returns null and warns when file exceeds 25MB", async () => {
56
+ const bigFile = join(tmpdir(), `voice-test-big-${Date.now()}.webm`);
57
+ writeFileSync(bigFile, Buffer.alloc(26 * 1024 * 1024));
58
+
59
+ const warnMsgs: string[] = [];
60
+ const origWarn = console.warn;
61
+ console.warn = (...args: unknown[]) => warnMsgs.push(String(args[0]));
62
+
63
+ const result = await transcribeAudio(bigFile, testConfig);
64
+
65
+ console.warn = origWarn;
66
+ unlinkSync(bigFile);
67
+
68
+ expect(result).toBeNull();
69
+ expect(warnMsgs.some((m) => m.includes("25MB"))).toBe(true);
70
+ });
71
+
72
+ test("returns null and warns when file does not exist", async () => {
73
+ const warnMsgs: string[] = [];
74
+ const origWarn = console.warn;
75
+ console.warn = (...args: unknown[]) => warnMsgs.push(String(args[0]));
76
+
77
+ const result = await transcribeAudio("/nonexistent/audio.webm", testConfig);
78
+
79
+ console.warn = origWarn;
80
+ expect(result).toBeNull();
81
+ expect(warnMsgs.some((m) => m.includes("could not read"))).toBe(true);
82
+ });
83
+
84
+ test("calls Groq API and returns transcription text on success", async () => {
85
+ const mockFetch = mock(async (_url: string, _opts: unknown) =>
86
+ Response.json({ text: "hello world" }),
87
+ );
88
+
89
+ const result = await transcribeAudio(
90
+ tmpFile,
91
+ testConfig,
92
+ mockFetch as unknown as typeof fetch,
93
+ );
94
+
95
+ expect(result).toBe("hello world");
96
+ expect(mockFetch).toHaveBeenCalledTimes(1);
97
+ const [url] = mockFetch.mock.calls[0] as [string, unknown];
98
+ expect(url).toContain("groq.com");
99
+ });
100
+
101
+ test("includes Authorization header with groqApiKey", async () => {
102
+ const capturedHeaders: Record<string, string>[] = [];
103
+ const mockFetch = mock(async (_url: string, opts: RequestInit) => {
104
+ capturedHeaders.push(opts.headers as Record<string, string>);
105
+ return Response.json({ text: "test" });
106
+ });
107
+
108
+ await transcribeAudio(
109
+ tmpFile,
110
+ testConfig,
111
+ mockFetch as unknown as typeof fetch,
112
+ );
113
+
114
+ expect(capturedHeaders[0]?.Authorization).toBe("Bearer test-groq-key");
115
+ });
116
+
117
+ test("returns null and logs error on non-ok Groq response, then tries whisper-svc", async () => {
118
+ const mockFetch = mock(
119
+ async () => new Response("Bad request", { status: 400 }),
120
+ );
121
+
122
+ const errorMsgs: string[] = [];
123
+ const origError = console.error;
124
+ console.error = (...args: unknown[]) => errorMsgs.push(String(args[0]));
125
+
126
+ const nullClient: WhisperClientFn = async () => null;
127
+ const result = await transcribeAudio(
128
+ tmpFile,
129
+ testConfig,
130
+ mockFetch as unknown as typeof fetch,
131
+ nullClient,
132
+ );
133
+
134
+ console.error = origError;
135
+
136
+ expect(result).toBeNull();
137
+ expect(errorMsgs.some((m) => m.includes("Groq STT failed"))).toBe(true);
138
+ });
139
+
140
+ test("returns null and logs warning on Groq fetch error, then tries whisper-svc", async () => {
141
+ const mockFetch = mock(async () => {
142
+ throw new Error("network error");
143
+ });
144
+
145
+ const errorMsgs: string[] = [];
146
+ const origError = console.error;
147
+ console.error = (...args: unknown[]) => errorMsgs.push(String(args[0]));
148
+
149
+ const nullClient: WhisperClientFn = async () => null;
150
+ const result = await transcribeAudio(
151
+ tmpFile,
152
+ testConfig,
153
+ mockFetch as unknown as typeof fetch,
154
+ nullClient,
155
+ );
156
+
157
+ console.error = origError;
158
+
159
+ expect(result).toBeNull();
160
+ expect(errorMsgs.some((m) => m.includes("request failed"))).toBe(true);
161
+ });
162
+ });
163
+
164
+ // ─── makeWhisperSvcClient ─────────────────────────────────────────────────────
165
+
166
+ describe("makeWhisperSvcClient — HTTP transcription client", () => {
167
+ let tmpFile: string;
168
+
169
+ beforeEach(() => {
170
+ tmpFile = makeTempAudioFile(1000);
171
+ });
172
+
173
+ afterEach(() => {
174
+ if (tmpFile && existsSync(tmpFile)) unlinkSync(tmpFile);
175
+ });
176
+
177
+ test("posts audio to /asr and returns transcription text", async () => {
178
+ const fakeFetch = mock(
179
+ async (_url: string, _opts: RequestInit) =>
180
+ new Response("transcribed text", { status: 200 }),
181
+ );
182
+ const client = makeWhisperSvcClient(
183
+ "http://whisper-svc:8000",
184
+ fakeFetch as unknown as typeof fetch,
185
+ );
186
+
187
+ const buffer = Buffer.from("audio data");
188
+ const result = await client(buffer, "test.webm");
189
+
190
+ expect(result).toBe("transcribed text");
191
+ expect(fakeFetch).toHaveBeenCalledTimes(1);
192
+ const [url] = fakeFetch.mock.calls[0] as [string, RequestInit];
193
+ expect(url.startsWith("http://whisper-svc:8000/asr")).toBe(true);
194
+ });
195
+
196
+ test("sends audio as multipart form data", async () => {
197
+ const capturedOpts: RequestInit[] = [];
198
+ const fakeFetch = mock(async (_url: string, opts: RequestInit) => {
199
+ capturedOpts.push(opts);
200
+ return new Response("ok", { status: 200 });
201
+ });
202
+ const client = makeWhisperSvcClient(
203
+ "http://whisper-svc:8000",
204
+ fakeFetch as unknown as typeof fetch,
205
+ );
206
+
207
+ const buffer = Buffer.from("audio bytes");
208
+ await client(buffer, "recording.webm");
209
+
210
+ expect(capturedOpts[0]?.method).toBe("POST");
211
+ expect(capturedOpts[0]?.body).toBeInstanceOf(FormData);
212
+ });
213
+
214
+ test("returns null and warns when service responds with non-2xx status", async () => {
215
+ const fakeFetch = mock(
216
+ async () => new Response("Service Unavailable", { status: 503 }),
217
+ );
218
+ const client = makeWhisperSvcClient(
219
+ "http://whisper-svc:8000",
220
+ fakeFetch as unknown as typeof fetch,
221
+ );
222
+
223
+ const warnMsgs: string[] = [];
224
+ const origWarn = console.warn;
225
+ console.warn = (...args: unknown[]) => warnMsgs.push(String(args[0]));
226
+
227
+ const result = await client(Buffer.from("audio"), "test.webm");
228
+
229
+ console.warn = origWarn;
230
+
231
+ expect(result).toBeNull();
232
+ expect(warnMsgs.some((m) => m.includes("whisper-svc"))).toBe(true);
233
+ });
234
+
235
+ test("returns null and warns on network error", async () => {
236
+ const fakeFetch = mock(async () => {
237
+ throw new Error("connection refused");
238
+ });
239
+ const client = makeWhisperSvcClient(
240
+ "http://whisper-svc:8000",
241
+ fakeFetch as unknown as typeof fetch,
242
+ );
243
+
244
+ const warnMsgs: string[] = [];
245
+ const origWarn = console.warn;
246
+ console.warn = (...args: unknown[]) => warnMsgs.push(String(args[0]));
247
+
248
+ const result = await client(Buffer.from("audio"), "test.webm");
249
+
250
+ console.warn = origWarn;
251
+
252
+ expect(result).toBeNull();
253
+ expect(warnMsgs.some((m) => m.includes("whisper-svc"))).toBe(true);
254
+ });
255
+
256
+ test("uses the audio filename as the form field filename", async () => {
257
+ const capturedBodies: FormData[] = [];
258
+ const fakeFetch = mock(async (_url: string, opts: RequestInit) => {
259
+ capturedBodies.push(opts.body as FormData);
260
+ return new Response("ok", { status: 200 });
261
+ });
262
+ const client = makeWhisperSvcClient(
263
+ "http://whisper-svc:8000",
264
+ fakeFetch as unknown as typeof fetch,
265
+ );
266
+
267
+ await client(Buffer.from("audio"), "/tmp/my-recording.webm");
268
+
269
+ const formData = capturedBodies[0];
270
+ expect(formData).toBeDefined();
271
+ const file = formData?.get("audio_file") as File | null;
272
+ expect(file?.name).toBe("my-recording.webm");
273
+ });
274
+ });
275
+
276
+ // ─── synthesizeSpeech (ElevenLabs) ────────────────────────────────────────────
277
+
278
+ describe("synthesizeSpeech — ElevenLabs", () => {
279
+ test("returns null when no keys are set and Piper fallback fails", async () => {
280
+ const result = await synthesizeSpeech(
281
+ "hello",
282
+ {},
283
+ undefined,
284
+ makeFailExitSpawn(1),
285
+ );
286
+
287
+ expect(result).toBeNull();
288
+ });
289
+
290
+ test("returns an audio path when Piper fallback succeeds", async () => {
291
+ const result = await synthesizeSpeech(
292
+ "hello",
293
+ {},
294
+ undefined,
295
+ makeSuccessSpawn(""),
296
+ );
297
+
298
+ expect(result).not.toBeNull();
299
+ expect(result).toContain("response.wav");
300
+ });
301
+
302
+ test("calls ElevenLabs API with elevenLabsApiKey and voiceId", async () => {
303
+ const capturedCalls: Array<{ url: string; opts: RequestInit }> = [];
304
+ const mockFetch = mock(async (url: string, opts: RequestInit) => {
305
+ capturedCalls.push({ url, opts });
306
+ return new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 });
307
+ });
308
+
309
+ const result = await synthesizeSpeech(
310
+ "hello",
311
+ testConfig,
312
+ mockFetch as unknown as typeof fetch,
313
+ );
314
+
315
+ expect(capturedCalls).toHaveLength(1);
316
+ expect(capturedCalls[0].url).toContain("elevenlabs.io");
317
+ expect(capturedCalls[0].url).toContain("test-voice-id");
318
+ const headers = capturedCalls[0].opts.headers as Record<string, string>;
319
+ expect(headers["xi-api-key"]).toBe("test-eleven-key");
320
+ expect(result).not.toBeNull();
321
+ expect(result).toContain(".mp3");
322
+ });
323
+
324
+ test("uses default Roger voiceId when voiceId is not set", async () => {
325
+ const capturedUrls: string[] = [];
326
+ const mockFetch = mock(async (url: string) => {
327
+ capturedUrls.push(url);
328
+ return new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 });
329
+ });
330
+
331
+ await synthesizeSpeech(
332
+ "hello",
333
+ { elevenLabsApiKey: "test-key" },
334
+ mockFetch as unknown as typeof fetch,
335
+ );
336
+
337
+ expect(capturedUrls[0]).toContain("CwhRBWXzGAHq8TQ4Fs17");
338
+ });
339
+
340
+ test("returns null and logs error on non-ok ElevenLabs response", async () => {
341
+ const mockFetch = mock(async () => new Response("error", { status: 500 }));
342
+
343
+ const errorMsgs: string[] = [];
344
+ const origError = console.error;
345
+ console.error = (...args: unknown[]) => errorMsgs.push(String(args[0]));
346
+
347
+ const result = await synthesizeSpeech(
348
+ "hello",
349
+ testConfig,
350
+ mockFetch as unknown as typeof fetch,
351
+ );
352
+
353
+ console.error = origError;
354
+
355
+ expect(result).toBeNull();
356
+ expect(errorMsgs.some((m) => m.includes("ElevenLabs TTS failed"))).toBe(
357
+ true,
358
+ );
359
+ });
360
+
361
+ test("cleans up by writing mp3 file to tmp voice dir", async () => {
362
+ const mockFetch = mock(async () => {
363
+ return new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 });
364
+ });
365
+
366
+ const outPath = await synthesizeSpeech(
367
+ "hello",
368
+ testConfig,
369
+ mockFetch as unknown as typeof fetch,
370
+ );
371
+
372
+ expect(outPath).not.toBeNull();
373
+ if (outPath) {
374
+ expect(existsSync(outPath)).toBe(true);
375
+ unlinkSync(outPath);
376
+ }
377
+ });
378
+ });
379
+
380
+ // ─── Helpers for subprocess injection (TTS only) ─────────────────────────────
381
+
382
+ type SpawnFn = (
383
+ command: string,
384
+ args: string[],
385
+ options?: SpawnOptions,
386
+ ) => ChildProcess;
387
+
388
+ function makeSuccessSpawn(output: string): SpawnFn {
389
+ return (_cmd, _args, _opts) => {
390
+ // biome-ignore lint/suspicious/noExplicitAny: test helper needs dynamic property assignment
391
+ const proc: any = new EventEmitter();
392
+ proc.stdout = new EventEmitter();
393
+ proc.stderr = new EventEmitter();
394
+ proc.stdin = { write: () => {}, end: () => {}, destroy: () => {} };
395
+ setImmediate(() => {
396
+ if (output) proc.stdout.emit("data", Buffer.from(output));
397
+ proc.emit("close", 0);
398
+ });
399
+ return proc as unknown as ChildProcess;
400
+ };
401
+ }
402
+
403
+ function makeFailExitSpawn(code: number): SpawnFn {
404
+ return (_cmd, _args, _opts) => {
405
+ // biome-ignore lint/suspicious/noExplicitAny: test helper needs dynamic property assignment
406
+ const proc: any = new EventEmitter();
407
+ proc.stdout = new EventEmitter();
408
+ proc.stderr = new EventEmitter();
409
+ proc.stdin = { write: () => {}, end: () => {}, destroy: () => {} };
410
+ setImmediate(() => {
411
+ proc.emit("close", code);
412
+ });
413
+ return proc as unknown as ChildProcess;
414
+ };
415
+ }
416
+
417
+ // ─── transcribeAudio — fallback chain ────────────────────────────────────────
418
+
419
+ describe("transcribeAudio — fallback chain", () => {
420
+ let tmpFile: string;
421
+
422
+ beforeEach(() => {
423
+ tmpFile = join(tmpdir(), `voice-fallback-test-${Date.now()}.webm`);
424
+ writeFileSync(tmpFile, Buffer.alloc(1000));
425
+ });
426
+
427
+ afterEach(() => {
428
+ if (tmpFile && existsSync(tmpFile)) unlinkSync(tmpFile);
429
+ });
430
+
431
+ test("uses injected whisper-svc client when groqApiKey is not set", async () => {
432
+ const whisperClient: WhisperClientFn = async () => "whisper result";
433
+ const result = await transcribeAudio(tmpFile, {}, undefined, whisperClient);
434
+ expect(result).toBe("whisper result");
435
+ });
436
+
437
+ test("uses whisper-svc URL from voiceConfig when no client is injected", async () => {
438
+ const fakeFetch = mock(
439
+ async () => new Response("from config url", { status: 200 }),
440
+ );
441
+ const configWithUrl: VoiceConfig = {
442
+ whisperServiceUrl: "http://whisper-svc:8000",
443
+ };
444
+
445
+ const result = await transcribeAudio(
446
+ tmpFile,
447
+ configWithUrl,
448
+ fakeFetch as unknown as typeof fetch,
449
+ );
450
+
451
+ expect(result).toBe("from config url");
452
+ });
453
+
454
+ test("falls back to whisper-svc client when Groq returns null due to fetch error", async () => {
455
+ const failingFetch = mock(async () => {
456
+ throw new Error("network error");
457
+ });
458
+
459
+ const origError = console.error;
460
+ console.error = () => {};
461
+
462
+ const whisperClient: WhisperClientFn = async () => "fallback";
463
+ const result = await transcribeAudio(
464
+ tmpFile,
465
+ testConfig,
466
+ failingFetch as unknown as typeof fetch,
467
+ whisperClient,
468
+ );
469
+
470
+ console.error = origError;
471
+
472
+ expect(result).toBe("fallback");
473
+ });
474
+
475
+ test("returns Groq result without invoking whisper-svc client when Groq succeeds", async () => {
476
+ const mockFetch = mock(async () => Response.json({ text: "groq result" }));
477
+
478
+ const throwingClient: WhisperClientFn = async () => {
479
+ throw new Error("whisper client should not have been called");
480
+ };
481
+ const result = await transcribeAudio(
482
+ tmpFile,
483
+ testConfig,
484
+ mockFetch as unknown as typeof fetch,
485
+ throwingClient,
486
+ );
487
+
488
+ expect(result).toBe("groq result");
489
+ });
490
+
491
+ test("returns null when no groqApiKey and no whisper client or URL configured", async () => {
492
+ const result = await transcribeAudio(tmpFile, {});
493
+ expect(result).toBeNull();
494
+ });
495
+
496
+ test("returns null when Groq key present but both Groq and whisper-svc client fail", async () => {
497
+ const failingFetch = mock(async () => {
498
+ throw new Error("network error");
499
+ });
500
+ const origError = console.error;
501
+ console.error = () => {};
502
+
503
+ const nullClient: WhisperClientFn = async () => null;
504
+ const result = await transcribeAudio(
505
+ tmpFile,
506
+ testConfig,
507
+ failingFetch as unknown as typeof fetch,
508
+ nullClient,
509
+ );
510
+
511
+ console.error = origError;
512
+ expect(result).toBeNull();
513
+ });
514
+ });
515
+
516
+ // ─── Helpers for subprocess injection (Piper dispatch) ───────────────────────
517
+
518
+ // Builds a fake ChildProcess and schedules `onImmediate` to fire on it once
519
+ // stdout/stderr/stdin are wired up — mirrors the shape spawnFn callers expect
520
+ // (proc.stdout/stderr as EventEmitters, proc.stdin.write/end) without a real
521
+ // subprocess. `onImmediate` also receives the args the real spawnPiper call
522
+ // was made with, so callers can locate the `--output_file` path a real
523
+ // Piper binary would write to.
524
+ function makeFakePiperSpawn(
525
+ onImmediate: (proc: EventEmitter, args: string[]) => void,
526
+ ): SpawnFn {
527
+ return (_cmd, args, _opts) => {
528
+ // biome-ignore lint/suspicious/noExplicitAny: test helper needs dynamic property assignment
529
+ const proc: any = new EventEmitter();
530
+ proc.stdout = new EventEmitter();
531
+ proc.stderr = new EventEmitter();
532
+ proc.stdin = { write: () => {}, end: () => {} };
533
+ setImmediate(() => onImmediate(proc, args));
534
+ return proc as unknown as ChildProcess;
535
+ };
536
+ }
537
+
538
+ // Extracts the path following `--output_file` from the args synthesizePiper
539
+ // spawns with, mirroring how the real piper binary locates its output path.
540
+ function outputFileFromArgs(args: string[]): string {
541
+ const idx = args.indexOf("--output_file");
542
+ if (idx === -1 || idx === args.length - 1) {
543
+ throw new Error("--output_file not found in spawn args");
544
+ }
545
+ return args[idx + 1];
546
+ }
547
+
548
+ // Mirrors what the real piper binary does: writes non-empty audio bytes to
549
+ // the `--output_file` path before exiting 0. Lets tests assert a real file
550
+ // was written, not just that a path string was returned.
551
+ const makeSuccessSpawnPiper = () =>
552
+ makeFakePiperSpawn((proc, args) => {
553
+ writeFileSync(outputFileFromArgs(args), Buffer.from([1, 2, 3, 4]));
554
+ proc.emit("close", 0);
555
+ });
556
+
557
+ const makeFailExitSpawnPiper = (code: number) =>
558
+ makeFakePiperSpawn((proc) => {
559
+ // biome-ignore lint/suspicious/noExplicitAny: fake proc's stderr is untyped
560
+ (proc as any).stderr.emit("data", Buffer.from("piper: model load error"));
561
+ proc.emit("close", code);
562
+ });
563
+
564
+ const makeSpawnErrorSpawnPiper = () =>
565
+ makeFakePiperSpawn((proc) =>
566
+ proc.emit("error", new Error("ENOENT: piper binary not found")),
567
+ );
568
+
569
+ // ─── synthesizeSpeech dispatch — ElevenLabs vs Piper ─────────────────────────
570
+
571
+ describe("synthesizeSpeech — dispatch (ElevenLabs vs Piper)", () => {
572
+ // Spy on console.error by swapping the bound method (not a global
573
+ // override) and restoring it after each test — mirrors the pattern in
574
+ // piper-voice.unit.test.ts.
575
+ let errorCalls: unknown[][] = [];
576
+ let originalError: typeof console.error;
577
+
578
+ beforeEach(() => {
579
+ errorCalls = [];
580
+ originalError = console.error;
581
+ console.error = (...args: unknown[]) => {
582
+ errorCalls.push(args);
583
+ };
584
+ });
585
+
586
+ afterEach(() => {
587
+ console.error = originalError;
588
+ });
589
+
590
+ test("with ELEVENLABS_API_KEY set, ElevenLabs is called and Piper's spawnFn is never invoked", async () => {
591
+ const mockFetch = mock(
592
+ async () =>
593
+ new Response(new Uint8Array([1, 2, 3]).buffer, { status: 200 }),
594
+ );
595
+ const spawnFn = mock((() => {
596
+ throw new Error(
597
+ "spawnFn should not be called when ElevenLabs key is set",
598
+ );
599
+ }) as unknown as SpawnFn);
600
+
601
+ const outPath = await synthesizeSpeech(
602
+ "hello",
603
+ { elevenLabsApiKey: "test-eleven-key" },
604
+ mockFetch as unknown as typeof fetch,
605
+ spawnFn,
606
+ );
607
+
608
+ expect(mockFetch).toHaveBeenCalledTimes(1);
609
+ expect(spawnFn).not.toHaveBeenCalled();
610
+ expect(outPath).not.toBeNull();
611
+ expect(outPath).toContain(".mp3");
612
+ if (outPath) unlinkSync(outPath);
613
+ });
614
+
615
+ test("with no ELEVENLABS_API_KEY and a working Piper, synthesizeSpeech returns a path to a non-empty .wav file", async () => {
616
+ const outPath = await synthesizeSpeech(
617
+ "hello",
618
+ {},
619
+ undefined,
620
+ makeSuccessSpawnPiper(),
621
+ );
622
+
623
+ expect(outPath).not.toBeNull();
624
+ expect(outPath).toContain(".wav");
625
+ if (outPath) {
626
+ expect(existsSync(outPath)).toBe(true);
627
+ expect(statSync(outPath).size).toBeGreaterThan(0);
628
+ unlinkSync(outPath);
629
+ }
630
+ });
631
+
632
+ test("when Piper exits non-zero, synthesizeSpeech returns null with no throw", async () => {
633
+ let result: string | null = null;
634
+ let threw = false;
635
+ try {
636
+ result = await synthesizeSpeech(
637
+ "hello",
638
+ {},
639
+ undefined,
640
+ makeFailExitSpawnPiper(1),
641
+ );
642
+ } catch {
643
+ threw = true;
644
+ }
645
+
646
+ expect(threw).toBe(false);
647
+ expect(result).toBeNull();
648
+ expect(errorCalls.length).toBeGreaterThan(0);
649
+ });
650
+
651
+ test("when the Piper spawn itself errors (binary absent), synthesizeSpeech returns null with no throw", async () => {
652
+ let result: string | null = null;
653
+ let threw = false;
654
+ try {
655
+ result = await synthesizeSpeech(
656
+ "hello",
657
+ {},
658
+ undefined,
659
+ makeSpawnErrorSpawnPiper(),
660
+ );
661
+ } catch {
662
+ threw = true;
663
+ }
664
+
665
+ expect(threw).toBe(false);
666
+ expect(result).toBeNull();
667
+ expect(errorCalls.length).toBeGreaterThan(0);
668
+ });
669
+ });