Open History to see the last commit
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 resetVoiceInputGlobalsForTests,
5 resolveTranscriptCommit,
6 VoiceInputController,
7 VOICE_RECORDING_LIMIT_SECONDS,
8 type VoiceDraftSnapshot,
9 type VoiceInputControllerDependencies,
10 type VoiceRecorder,
11} from "./controller.ts";
12import {
13 VoiceTranscriptionError,
14 type PreparedVoiceTranscription,
15 type VoiceTranscriber,
16} from "./transcription.ts";
17
18function deferred<T>() {
19 let resolve!: (value: T) => void;
20 let reject!: (error: unknown) => void;
21 const promise = new Promise<T>((resolvePromise, rejectPromise) => {
22 resolve = resolvePromise;
23 reject = rejectPromise;
24 });
25 return { promise, resolve, reject };
26}
27
28class TestRecorder implements VoiceRecorder {
29 uri: string | null = "file:///voice.m4a";
30 readonly prepareToRecordAsync = vi.fn(async () => undefined);
31 readonly record = vi.fn();
32 readonly stop = vi.fn(async () => undefined);
33}
34
35function preparedTranscription(
36 transcribe: PreparedVoiceTranscription["transcribe"] = async () => "new text",
37): PreparedVoiceTranscription {
38 return { locale: "en-US", transcribe };
39}
40
41function preparedStreamingTranscription(
42 finish: (options: { readonly signal: AbortSignal }) => Promise<string>,
43): PreparedVoiceTranscription {
44 return { ...preparedTranscription(), streaming: { finish } };
45}
46
47function draft(overrides: Partial<VoiceDraftSnapshot> = {}): VoiceDraftSnapshot {
48 return {
49 ownerKey: "environment:thread",
50 text: "hello world",
51 selection: { start: 6, end: 11 },
52 ...overrides,
53 };
54}
55
56function createHarness(
57 overrides: Partial<VoiceInputControllerDependencies> = {},
58 initialDraft = draft(),
59) {
60 const recorder = new TestRecorder();
61 let currentDraft: VoiceDraftSnapshot | null = initialDraft;
62 const commits: Array<{ text: string; selection: { start: number; end: number } }> = [];
63 const deleted: string[] = [];
64 const dependencies: VoiceInputControllerDependencies = {
65 recorder,
66 getTranscriber: () => ({ prepare: async () => preparedTranscription() }),
67 requestPermission: async () => ({ granted: true, canAskAgain: true }),
68 configureRecording: async () => undefined,
69 releaseRecording: async () => undefined,
70 deleteRecording: (uri) => deleted.push(uri),
71 readDraft: () => currentDraft,
72 commitDraft: (text, selection) => commits.push({ text, selection }),
73 onStateChange: vi.fn(),
74 ...overrides,
75 };
76 return {
77 controller: new VoiceInputController(dependencies),
78 recorder,
79 commits,
80 deleted,
81 setDraft: (next: VoiceDraftSnapshot | null) => {
82 currentDraft = next;
83 },
84 };
85}
86
87describe("environment voice input", () => {
88 beforeEach(() => resetVoiceInputGlobalsForTests());
89
90 it("retries failed batch audio without recording again, then deletes it", async () => {
91 const transcribe = vi
92 .fn<PreparedVoiceTranscription["transcribe"]>()
93 .mockRejectedValueOnce(new Error("inference failed"))
94 .mockResolvedValueOnce("recovered text");
95 const harness = createHarness({
96 retryFailedRecording: true,
97 getTranscriber: () => ({ prepare: async () => preparedTranscription(transcribe) }),
98 });
99 await harness.controller.start();
100 await harness.controller.stop();
101 expect(harness.controller.currentState.phase).toBe("error");
102 expect(harness.deleted).toEqual([]);
103
104 harness.setDraft(draft({ text: "updated ", selection: { start: 8, end: 8 } }));
105 await harness.controller.start();
106 expect(transcribe.mock.calls.map(([uri]) => uri)).toEqual([
107 "file:///voice.m4a",
108 "file:///voice.m4a",
109 ]);
110 expect(harness.recorder.record).toHaveBeenCalledOnce();
111 expect(harness.commits[0]?.text).toBe("updated recovered text");
112 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
113 });
114
115 it("deletes retained failed audio when the user cancels instead of retrying", async () => {
116 const harness = createHarness({
117 retryFailedRecording: true,
118 getTranscriber: () => ({
119 prepare: async () =>
120 preparedTranscription(async () => {
121 throw new Error("failed");
122 }),
123 }),
124 });
125 await harness.controller.start();
126 await harness.controller.stop();
127 harness.controller.cancel();
128 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
129 });
130
131 it("flushes capture before finishing a stream and commits once without a file", async () => {
132 const events: string[] = [];
133 const harness = createHarness({
134 getTranscriber: () => ({
135 prepare: async () =>
136 preparedStreamingTranscription(async () => {
137 events.push("finish");
138 return "new text";
139 }),
140 }),
141 });
142 harness.recorder.uri = null;
143 harness.recorder.stop.mockImplementation(async () => {
144 events.push("flush");
145 });
146 await harness.controller.start();
147 await harness.controller.stop();
148 expect(events).toEqual(["flush", "finish"]);
149 expect(harness.commits).toEqual([
150 { text: "hello new text", selection: { start: 14, end: 14 } },
151 ]);
152 });
153
154 it("does not commit a late stream result after cancellation", async () => {
155 const started = deferred<void>();
156 const result = deferred<string>();
157 const harness = createHarness({
158 getTranscriber: () => ({
159 prepare: async () =>
160 preparedStreamingTranscription(() => {
161 started.resolve();
162 return result.promise;
163 }),
164 }),
165 });
166 harness.recorder.uri = null;
167 await harness.controller.start();
168 const pending = harness.controller.stop();
169 await started.promise;
170 harness.controller.cancel();
171 result.resolve("late result");
172 await pending;
173 expect(harness.commits).toEqual([]);
174 expect(harness.controller.currentState.phase).toBe("idle");
175 });
176
177 it("closes a prepared stream when the microphone cannot start", async () => {
178 let signal: AbortSignal | undefined;
179 const harness = createHarness({
180 getTranscriber: () => ({
181 prepare: async (options) => {
182 signal = options.signal;
183 return preparedStreamingTranscription(async () => "unused");
184 },
185 }),
186 });
187 harness.recorder.prepareToRecordAsync.mockRejectedValue(new Error("Microphone unavailable"));
188 await harness.controller.start();
189 expect(signal?.aborted).toBe(true);
190 expect(harness.controller.currentState.phase).toBe("error");
191 });
192
193 it("commits the raw transcript once when cleanup is skipped", async () => {
194 const started = deferred<void>();
195 const processed = deferred<string>();
196 const harness = createHarness({
197 postProcess: async () => {
198 started.resolve();
199 return processed.promise;
200 },
201 });
202 await harness.controller.start();
203 const stopping = harness.controller.stop();
204 await started.promise;
205 expect(harness.controller.currentState.phase).toBe("post-processing");
206 harness.controller.skipPostProcessing();
207 await stopping;
208 processed.resolve("late text");
209 await processed.promise;
210 expect(harness.commits).toEqual([
211 { text: "hello new text", selection: { start: 14, end: 14 } },
212 ]);
213 });
214
215 it("falls back to the raw transcript and reports a cleanup failure", async () => {
216 const onPostProcessingError = vi.fn();
217 const harness = createHarness({
218 postProcess: async () => {
219 throw new Error("provider failed");
220 },
221 onPostProcessingError,
222 });
223 await harness.controller.start();
224 await harness.controller.stop();
225 expect(harness.commits).toEqual([
226 { text: "hello new text", selection: { start: 14, end: 14 } },
227 ]);
228 expect(onPostProcessingError).toHaveBeenCalledOnce();
229 });
230});
231
232describe("resolveTranscriptCommit", () => {
233 it.each([
234 ["Hello", "world", "Hello world"],
235 ["Hello", "世界", "Hello世界"],
236 ["Hello", "𝟚", "Hello 𝟚"],
237 ["你好", "世界", "你好世界"],
238 ])(
239 "preserves script boundaries with an autodetected language: %s + %s",
240 (text, spoken, expected) => {
241 const captured = draft({ text, selection: { start: text.length, end: text.length } });
242 expect(resolveTranscriptCommit(captured, captured, spoken, "auto")).toMatchObject({
243 kind: "commit",
244 text: expected,
245 });
246 },
247 );
248
249 it.each([
250 ["Korean", "코드를", 3, "검토해 주세요", "ko_KR", "코드를 검토해 주세요"],
251 ["a supplementary letter", "𞤀", 2, "𞤦", "ff-Adlm", "𞤀 𞤦"],
252 ["a trailing combining mark", "नमस्ते", 6, "दुनिया", "hi-IN", "नमस्ते दुनिया"],
253 ["a non-ASCII word after the caret", "Änderungen", 0, "Weitere", "de_DE", "Weitere Änderungen"],
254 ["spaceless Japanese", "修正キャッシュ", 7, "テストも", "ja-JP", "修正キャッシュテストも"],
255 ["spaceless Thai", "โลก", 0, "สวัสดี", "th-TH", "สวัสดีโลก"],
256 ])(
257 "inserts at a caret with the recorded language's word spacing: %s",
258 (_case, text, caret, spoken, locale, expected) => {
259 const captured = draft({ text, selection: { start: caret, end: caret } });
260 expect(resolveTranscriptCommit(captured, captured, spoken, locale)).toMatchObject({
261 kind: "commit",
262 text: expected,
263 });
264 },
265 );
266
267 it("replaces the recorded UTF-16 selection around emoji and composer tokens", () => {
268 const text = "Fix 🧪 then $review please";
269 const tokenStart = text.indexOf("$review");
270 const captured = draft({
271 text,
272 selection: { start: tokenStart, end: tokenStart + "$review".length },
273 });
274
275 expect(resolveTranscriptCommit(captured, captured, "use the mobile skill", "en-US")).toEqual({
276 kind: "commit",
277 text: "Fix 🧪 then use the mobile skill please",
278 selection: { start: tokenStart + "use the mobile skill".length, end: tokenStart + 20 },
279 });
280 });
281
282 it.each([
283 [
284 "at the start of the selection",
285 { start: 6, end: 11 },
286 "hello [cite]world",
287 "hello [cite]world new text",
288 ],
289 [
290 "inside the selection",
291 { start: 6, end: 11 },
292 "hello wo[cite]rld",
293 "hello wo[cite]rld new text",
294 ],
295 ["at the caret", { start: 6, end: 6 }, "hello [cite] world", "hello [cite] new text world"],
296 [
297 "that repeats the selection",
298 { start: 0, end: 5 },
299 "hello there hello world",
300 "hello there hello new text world",
301 ],
302 [
303 "that repeats the selection next to it",
304 { start: 0, end: 5 },
305 "hello hello world",
306 "hello hello new text world",
307 ],
308 ])("keeps text inserted %s while recording", (_, selection, current, expected) => {
309 const captured = draft({ text: "hello world", selection });
310 const result = resolveTranscriptCommit(
311 captured,
312 draft({ text: current, selection: { start: 0, end: 0 } }),
313 "new text",
314 "en-US",
315 );
316 expect(result).toMatchObject({ kind: "commit", text: expected });
317 });
318
319 it("keeps every citation when several were inserted while recording", () => {
320 const captured = draft({ text: "[todo", selection: { start: 0, end: 1 } });
321 const current = "[A](t3-citation://v1/a)[todo[B](t3-citation://v1/b)";
322 const result = resolveTranscriptCommit(
323 captured,
324 draft({ text: current, selection: { start: 0, end: 0 } }),
325 "spoken",
326 "en-US",
327 );
328 expect(result).toMatchObject({ kind: "commit", text: `${current} spoken` });
329 });
330
331 it("keeps surrogate pairs whole when an emoji changed while recording", () => {
332 const captured = draft({ text: "🔔", selection: { start: 0, end: 0 } });
333 const result = resolveTranscriptCommit(
334 captured,
335 draft({ text: "🤔", selection: { start: 0, end: 0 } }),
336 "hello",
337 "en-US",
338 );
339 expect(result).toMatchObject({ kind: "commit", text: "🤔hello" });
340 });
341
342 it("does not insert into a missing draft or another owner's draft", () => {
343 const captured = draft();
344 expect(resolveTranscriptCommit(captured, null, "text", "en-US")).toEqual({ kind: "stale" });
345 expect(
346 resolveTranscriptCommit(captured, draft({ ownerKey: "other" }), "text", "en-US"),
347 ).toEqual({
348 kind: "stale",
349 });
350 });
351});
352
353describe("VoiceInputController", () => {
354 beforeEach(() => resetVoiceInputGlobalsForTests());
355
356 it("checks support and permission before recording", async () => {
357 const unsupported = createHarness({ getTranscriber: () => null });
358 await unsupported.controller.start();
359 expect(unsupported.controller.currentState.error).toContain("not available");
360 expect(unsupported.recorder.record).not.toHaveBeenCalled();
361
362 const reason = new VoiceTranscriptionError({
363 code: "unavailable",
364 message: "This device requires iOS 26 or later.",
365 });
366 const configureRecording = vi.fn(async () => undefined);
367 const unavailable = createHarness({
368 configureRecording,
369 getTranscriber: () => ({
370 prepare: async () => {
371 throw reason;
372 },
373 }),
374 });
375 await unavailable.controller.start();
376 expect(unavailable.controller.currentState.error).toBe(reason.message);
377 // Environment and web transcribers gate capture on consent checked during preparation.
378 expect(configureRecording).not.toHaveBeenCalled();
379 expect(unavailable.recorder.record).not.toHaveBeenCalled();
380
381 const denied = createHarness({
382 requestPermission: async () => ({ granted: false, canAskAgain: false }),
383 });
384 await denied.controller.start();
385 expect(denied.controller.currentState.errorAction).toBe("settings");
386 expect(denied.recorder.record).not.toHaveBeenCalled();
387 });
388
389 it.each(["permission", "transcription"] as const)(
390 "clears %s errors when switching to another draft",
391 async (failure) => {
392 const harness = createHarness(
393 failure === "permission"
394 ? { requestPermission: async () => ({ granted: false, canAskAgain: false }) }
395 : {
396 getTranscriber: () => ({
397 prepare: async () =>
398 preparedTranscription(async () => {
399 throw new Error("Transcription failed");
400 }),
401 }),
402 },
403 );
404 await harness.controller.start();
405 await harness.controller.stop();
406 expect(harness.controller.currentState).toMatchObject({
407 phase: "error",
408 error: expect.any(String),
409 errorAction: failure === "permission" ? "settings" : "retry",
410 });
411
412 harness.setDraft(draft({ ownerKey: "environment:other-thread" }));
413 harness.controller.ownerChanged();
414
415 expect(harness.controller.currentState).toEqual({
416 phase: "idle",
417 error: null,
418 errorAction: null,
419 });
420 expect(harness.commits).toEqual([]);
421 },
422 );
423
424 it.each(["permission", "preparation", "recording"] as const)(
425 "keeps the selected transcriber when preferences change during %s",
426 async (changeDuring) => {
427 const permission = deferred<{ granted: boolean; canAskAgain: boolean }>();
428 const permissionEntered = deferred<void>();
429 const preparation = deferred<void>();
430 const preparationEntered = deferred<void>();
431 const preparationSignals: AbortSignal[] = [];
432 const transcriptionSignals: AbortSignal[] = [];
433 const transcriber = (text: string): VoiceTranscriber => ({
434 prepare: async ({ signal }) => {
435 preparationSignals.push(signal);
436 preparationEntered.resolve(undefined);
437 await preparation.promise;
438 return preparedTranscription(async (_uri, { signal }) => {
439 transcriptionSignals.push(signal);
440 return text;
441 });
442 },
443 });
444 const first = transcriber("first choice");
445 const second = transcriber("second choice");
446 let selected = first;
447 const harness = createHarness({
448 getTranscriber: () => selected,
449 requestPermission: () => {
450 permissionEntered.resolve(undefined);
451 return permission.promise;
452 },
453 });
454
455 const starting = harness.controller.start();
456 await permissionEntered.promise;
457 if (changeDuring === "permission") selected = second;
458 permission.resolve({ granted: true, canAskAgain: true });
459 await preparationEntered.promise;
460 if (changeDuring === "preparation") selected = second;
461 preparation.resolve(undefined);
462 await starting;
463 if (changeDuring === "recording") selected = second;
464 await harness.controller.stop();
465
466 expect(harness.commits.map((commit) => commit.text)).toEqual(["hello first choice"]);
467
468 await harness.controller.start();
469 await harness.controller.stop();
470
471 expect(harness.commits.map((commit) => commit.text)).toEqual([
472 "hello first choice",
473 "hello second choice",
474 ]);
475 expect(preparationSignals).toHaveLength(2);
476 expect(transcriptionSignals).toHaveLength(2);
477 expect(transcriptionSignals[0]).toBe(preparationSignals[0]);
478 expect(transcriptionSignals[1]).toBe(preparationSignals[1]);
479 expect(preparationSignals[1]).not.toBe(preparationSignals[0]);
480 },
481 );
482
483 it("uses the native five-minute cap and commits one final transcript", async () => {
484 const harness = createHarness();
485 await harness.controller.start();
486 expect(harness.recorder.record).toHaveBeenCalledWith({
487 forDuration: VOICE_RECORDING_LIMIT_SECONDS,
488 });
489 await harness.controller.handleRecorderStatus({
490 isFinished: true,
491 hasError: false,
492 error: null,
493 url: "file:///previous-recording.m4a",
494 });
495 expect(harness.controller.currentState.phase).toBe("recording");
496
497 const stopping = harness.controller.stop();
498 harness.controller.handleRecorderStatus({
499 isFinished: true,
500 hasError: false,
501 error: null,
502 url: "file:///voice.m4a",
503 });
504 await stopping;
505
506 expect(harness.commits).toEqual([
507 { text: "hello new text", selection: { start: 14, end: 14 } },
508 ]);
509 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
510 });
511
512 it.each(["cancel", "dispose", "ownerChanged"] as const)(
513 "queues the next recording after %s until transcription and audio cleanup settle",
514 async (action) => {
515 const transcription = deferred<string>();
516 const transcriptionEntered = deferred<AbortSignal>();
517 const audioRelease = deferred<void>();
518 const audioReleaseEntered = deferred<void>();
519 const releaseRecording = vi
520 .fn<() => Promise<void>>()
521 .mockRejectedValueOnce(new Error("busy"))
522 .mockImplementationOnce(() => {
523 audioReleaseEntered.resolve(undefined);
524 return audioRelease.promise;
525 });
526 const harness = createHarness({
527 releaseRecording,
528 getTranscriber: () => ({
529 prepare: async () =>
530 preparedTranscription((_uri, { signal }) => {
531 transcriptionEntered.resolve(signal);
532 return transcription.promise;
533 }),
534 }),
535 });
536 await harness.controller.start();
537 const stopping = harness.controller.stop();
538 const signal = await transcriptionEntered.promise;
539 expect(signal.aborted).toBe(false);
540 if (action === "ownerChanged") {
541 harness.setDraft(draft({ ownerKey: "environment:other-thread" }));
542 }
543 harness.controller[action]();
544 expect(signal.aborted).toBe(true);
545
546 const prepare = vi.fn(async () => preparedTranscription());
547 const next = createHarness({ getTranscriber: () => ({ prepare }) });
548 const nextStart = next.controller.start();
549 expect(next.controller.currentState).toEqual({
550 phase: "preparing",
551 error: null,
552 errorAction: null,
553 });
554 expect(prepare).not.toHaveBeenCalled();
555 expect(next.recorder.record).not.toHaveBeenCalled();
556
557 transcription.resolve("late text");
558 await audioReleaseEntered.promise;
559 expect(prepare).not.toHaveBeenCalled();
560 expect(next.recorder.prepareToRecordAsync).not.toHaveBeenCalled();
561 audioRelease.resolve(undefined);
562 await stopping;
563 await nextStart;
564
565 expect(harness.commits).toEqual([]);
566 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
567 expect(harness.controller.currentState.phase).toBe("idle");
568
569 expect(next.controller.currentState.phase).toBe("recording");
570 expect(prepare).toHaveBeenCalledTimes(1);
571 await next.controller.interruptRecording();
572 },
573 );
574
575 it("releases the microphone before transcription starts", async () => {
576 const events: string[] = [];
577 const harness = createHarness({
578 releaseRecording: async () => {
579 events.push("released");
580 },
581 getTranscriber: () => ({
582 prepare: async () =>
583 preparedTranscription(async () => {
584 events.push("transcribed");
585 return "done";
586 }),
587 }),
588 });
589 await harness.controller.start();
590 await harness.controller.stop();
591
592 expect(events).toEqual(["released", "transcribed"]);
593 });
594
595 it("starts streaming transcription during recording and commits only after stopping", async () => {
596 const transcript = deferred<string>();
597 const start = vi.fn(() => transcript.promise);
598 const transcribe = vi.fn(async () => "replayed audio");
599 const harness = createHarness({
600 getTranscriber: () => ({
601 prepare: async () => ({ locale: "en-US", start, transcribe }),
602 }),
603 });
604 await harness.controller.start();
605 expect(start).toHaveBeenCalledWith("file:///voice.m4a", {
606 signal: expect.any(AbortSignal),
607 });
608 expect(harness.controller.currentState.phase).toBe("recording");
609 expect(harness.commits).toEqual([]);
610
611 const stopping = harness.controller.stop();
612 transcript.resolve("opening middle and final words");
613 await stopping;
614 expect(harness.commits[0]?.text).toBe("hello opening middle and final words");
615 expect(transcribe).not.toHaveBeenCalled();
616 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
617 });
618
619 it("waits for canceled live inference before deleting its recording or starting again", async () => {
620 const transcript = deferred<string>();
621 const released = deferred<void>();
622 let signal: AbortSignal | undefined;
623 const harness = createHarness({
624 getTranscriber: () => ({
625 prepare: async () => ({
626 ...preparedTranscription(),
627 start: (_uri: string, options: { signal: AbortSignal }) => {
628 signal = options.signal;
629 return transcript.promise;
630 },
631 }),
632 }),
633 deleteRecording: () => released.resolve(undefined),
634 });
635 await harness.controller.start();
636 expect(signal).toBeDefined();
637 harness.controller.cancel();
638 expect(signal?.aborted).toBe(true);
639 const next = createHarness();
640 const nextStart = next.controller.start();
641 expect(next.recorder.record).not.toHaveBeenCalled();
642
643 transcript.resolve("discard this");
644 await released.promise;
645 await nextStart;
646 expect(harness.commits).toEqual([]);
647 expect(next.controller.currentState.phase).toBe("recording");
648 await next.controller.interruptRecording();
649 });
650
651 it("stops recording after a live inference failure and preserves the draft for retry", async () => {
652 const transcript = deferred<string>();
653 const released = deferred<void>();
654 const harness = createHarness({
655 getTranscriber: () => ({
656 prepare: async () => ({ ...preparedTranscription(), start: () => transcript.promise }),
657 }),
658 deleteRecording: () => released.resolve(undefined),
659 });
660 await harness.controller.start();
661 transcript.reject(
662 new VoiceTranscriptionError({
663 code: "transcription-failed",
664 message: "Speech quota reached.",
665 }),
666 );
667 await released.promise;
668 expect(harness.controller.currentState).toMatchObject({
669 phase: "error",
670 error: "Speech quota reached.",
671 errorAction: "retry",
672 });
673 expect(harness.recorder.stop).toHaveBeenCalled();
674 expect(harness.commits).toEqual([]);
675 const next = createHarness();
676 await next.controller.start();
677 expect(next.controller.currentState.phase).toBe("recording");
678 await next.controller.interruptRecording();
679 });
680
681 it("ignores a late transcript after the draft owner changes", async () => {
682 const transcription = deferred<string>();
683 const transcriptionEntered = deferred<void>();
684 const harness = createHarness({
685 getTranscriber: () => ({
686 prepare: async () =>
687 preparedTranscription(() => {
688 transcriptionEntered.resolve(undefined);
689 return transcription.promise;
690 }),
691 }),
692 });
693 await harness.controller.start();
694 const stopping = harness.controller.stop();
695 await transcriptionEntered.promise;
696 harness.setDraft(draft({ ownerKey: "environment:other-thread" }));
697 transcription.resolve("late text");
698 await stopping;
699
700 expect(harness.commits).toEqual([]);
701 expect(harness.controller.currentState.error).toContain("draft changed");
702 });
703
704 it("queues the next recording until canceled preparation settles", async () => {
705 const preparation = deferred<PreparedVoiceTranscription>();
706 const preparationEntered = deferred<AbortSignal>();
707 const first = createHarness({
708 getTranscriber: () => ({
709 prepare: ({ signal }) => {
710 preparationEntered.resolve(signal);
711 return preparation.promise;
712 },
713 }),
714 });
715 const firstStart = first.controller.start();
716 const signal = await preparationEntered.promise;
717 first.controller.cancel();
718 expect(signal.aborted).toBe(true);
719
720 const next = createHarness();
721 const nextStart = next.controller.start();
722 expect(next.controller.currentState.phase).toBe("preparing");
723 expect(next.recorder.prepareToRecordAsync).not.toHaveBeenCalled();
724
725 preparation.resolve(preparedTranscription());
726 await firstStart;
727 expect(first.recorder.record).not.toHaveBeenCalled();
728 await nextStart;
729 expect(next.controller.currentState.phase).toBe("recording");
730 await next.controller.interruptRecording();
731 });
732
733 it("still rejects a second start while the first recording is active", async () => {
734 const first = createHarness();
735 await first.controller.start();
736 const next = createHarness();
737 await next.controller.start();
738 expect(next.controller.currentState.error).toContain("already active");
739 expect(next.recorder.record).not.toHaveBeenCalled();
740 await first.controller.interruptRecording();
741 });
742
743 it("does not start a canceled waiter or let it release another recording", async () => {
744 const preparation = deferred<PreparedVoiceTranscription>();
745 const preparationEntered = deferred<void>();
746 const first = createHarness({
747 getTranscriber: () => ({
748 prepare: () => {
749 preparationEntered.resolve(undefined);
750 return preparation.promise;
751 },
752 }),
753 });
754 const firstStart = first.controller.start();
755 await preparationEntered.promise;
756 first.controller.dispose();
757
758 const next = createHarness();
759 const canceledStart = next.controller.start();
760 next.controller.cancel();
761 const nextStart = next.controller.start();
762 preparation.resolve(preparedTranscription());
763 await Promise.all([firstStart, canceledStart, nextStart]);
764
765 expect(next.recorder.record).toHaveBeenCalledTimes(1);
766 expect(next.deleted).toEqual([]);
767 const blocked = createHarness();
768 await blocked.controller.start();
769 expect(blocked.controller.currentState.error).toContain("already active");
770 await next.controller.interruptRecording();
771 });
772
773 it("releases an abandoned recording even if its native recorder was already disposed", async () => {
774 const harness = createHarness();
775 await harness.controller.start();
776 harness.recorder.stop.mockRejectedValueOnce(new Error("recorder released"));
777 Object.defineProperty(harness.recorder, "uri", {
778 get() {
779 throw new Error("recorder released");
780 },
781 });
782
783 await expect(harness.controller.interruptRecording()).resolves.toBeUndefined();
784 expect(harness.deleted).toEqual(["file:///voice.m4a"]);
785 const next = createHarness();
786 await next.controller.start();
787 expect(next.controller.currentState.phase).toBe("recording");
788 await next.controller.interruptRecording();
789 });
790
791 it("keeps the new abort controller when restarting the same composer after cancellation", async () => {
792 const transcription = deferred<string>();
793 const transcriptionEntered = deferred<void>();
794 const prepare = vi
795 .fn<VoiceTranscriber["prepare"]>()
796 .mockResolvedValueOnce(
797 preparedTranscription(() => {
798 transcriptionEntered.resolve(undefined);
799 return transcription.promise;
800 }),
801 )
802 .mockResolvedValue(preparedTranscription());
803 const harness = createHarness({ getTranscriber: () => ({ prepare }) });
804 await harness.controller.start();
805 const stopping = harness.controller.stop();
806 await transcriptionEntered.promise;
807 harness.controller.cancel();
808 const restarting = harness.controller.start();
809 transcription.resolve("late text");
810 await Promise.all([stopping, restarting]);
811 await harness.controller.stop();
812
813 expect(harness.commits).toEqual([
814 { text: "hello new text", selection: { start: 14, end: 14 } },
815 ]);
816 expect(harness.controller.currentState.phase).toBe("idle");
817 });
818
819 it("does not start the microphone for an owner that changed during preparation", async () => {
820 const preparation = deferred<PreparedVoiceTranscription>();
821 const preparationEntered = deferred<void>();
822 const harness = createHarness({
823 getTranscriber: () => ({
824 prepare: () => {
825 preparationEntered.resolve(undefined);
826 return preparation.promise;
827 },
828 }),
829 });
830 const starting = harness.controller.start();
831 await preparationEntered.promise;
832 harness.setDraft(draft({ ownerKey: "environment:other-thread", text: "other draft" }));
833 preparation.resolve(preparedTranscription());
834 await starting;
835
836 expect(harness.recorder.record).not.toHaveBeenCalled();
837 expect(harness.controller.currentState.error).toContain("no longer available");
838 });
839
840 it("discards recorder errors and audio interruptions without transcribing", async () => {
841 const transcribe = vi.fn(async () => "ignored");
842 const preparationEntered = deferred<AbortSignal>();
843 const harness = createHarness({
844 getTranscriber: () => ({
845 prepare: async ({ signal }) => {
846 preparationEntered.resolve(signal);
847 return preparedTranscription(transcribe);
848 },
849 }),
850 });
851 await harness.controller.start();
852 const signal = await preparationEntered.promise;
853 harness.recorder.uri = "file:///reset-empty.m4a";
854 await harness.controller.handleRecorderStatus({
855 isFinished: true,
856 hasError: true,
857 error: "Audio route changed",
858 url: "file:///voice.m4a",
859 });
860
861 expect(harness.commits).toEqual([]);
862 expect(transcribe).not.toHaveBeenCalled();
863 expect(signal.aborted).toBe(true);
864 expect(harness.controller.currentState.error).toBe("Audio route changed");
865 expect(harness.deleted).toEqual(["file:///voice.m4a", "file:///reset-empty.m4a"]);
866 });
867
868 it("never starts capture after the stream fails while the microphone is opening", async () => {
869 const opened = deferred<void>();
870 const waiting = deferred<void>();
871 const releaseRecording = vi.fn(async () => {});
872 const harness = createHarness({ releaseRecording });
873 harness.recorder.prepareToRecordAsync.mockImplementationOnce(() => {
874 waiting.resolve();
875 return opened.promise.then(() => undefined);
876 });
877 const starting = harness.controller.start();
878 await waiting.promise;
879 harness.controller.interruptRecording("Environment is busy");
880 opened.resolve();
881 await starting;
882 expect(harness.recorder.record).not.toHaveBeenCalled();
883 expect(harness.controller.currentState).toMatchObject({
884 phase: "error",
885 error: "Environment is busy",
886 });
887 expect(releaseRecording).toHaveBeenCalledOnce();
888 });
889
890 it("cancels preparation when the app reaches the background", async () => {
891 const preparation = deferred<PreparedVoiceTranscription>();
892 const preparationEntered = deferred<AbortSignal>();
893 const harness = createHarness({
894 getTranscriber: () => ({
895 prepare: ({ signal }) => {
896 preparationEntered.resolve(signal);
897 return preparation.promise;
898 },
899 }),
900 });
901 const starting = harness.controller.start();
902 const signal = await preparationEntered.promise;
903 harness.controller.appMovedToBackground();
904 expect(signal.aborted).toBe(true);
905 preparation.resolve(preparedTranscription());
906 await starting;
907
908 expect(harness.recorder.record).not.toHaveBeenCalled();
909 expect(harness.controller.currentState.error).toContain("background");
910 });
911});