Skip to content

Commit a43f593

Browse files
Fix speech recognition test bugs and add Playwright e2e voice tests
Fix unit test mock missing resultIndex/isFinal causing transcript tests to silently fail. Fix incorrect "natural end" test that expected idle state during auto-restart. Add 5 Chromium-only Playwright e2e tests for voice transcription flow. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9a162fe commit a43f593

2 files changed

Lines changed: 246 additions & 2 deletions

File tree

components/CaptureView.test.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ function makeMockRecognition() {
4949
},
5050
_fireResult(transcript: string) {
5151
handlers.onresult?.({
52-
results: [[{ transcript }]],
52+
resultIndex: 0,
53+
results: [{ 0: { transcript }, isFinal: true, length: 1 }],
5354
});
5455
},
5556
_fireEnd() {
@@ -249,7 +250,7 @@ describe('CaptureView', () => {
249250
expect(screen.getByRole('button', { name: /start recording/i })).toBeInTheDocument();
250251
});
251252

252-
it('returns to idle state when recognition ends naturally', async () => {
253+
it('auto-restarts when recognition ends while still listening', async () => {
253254
const mockRec = makeMockRecognition();
254255
(window as unknown as Record<string, unknown>).SpeechRecognition = vi.fn(function () {
255256
return mockRec;
@@ -260,10 +261,32 @@ describe('CaptureView', () => {
260261
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
261262
});
262263

264+
mockRec.start.mockClear();
263265
await act(async () => {
264266
mockRec._fireEnd();
265267
});
266268

269+
// Should auto-restart, staying in listening state
270+
expect(mockRec.start).toHaveBeenCalledOnce();
271+
expect(screen.getByRole('button', { name: /stop recording/i })).toBeInTheDocument();
272+
});
273+
274+
it('returns to idle when recognition ends after user stops', async () => {
275+
const mockRec = makeMockRecognition();
276+
(window as unknown as Record<string, unknown>).SpeechRecognition = vi.fn(function () {
277+
return mockRec;
278+
});
279+
280+
render(<CaptureView />);
281+
await act(async () => {
282+
fireEvent.click(screen.getByRole('button', { name: /start recording/i }));
283+
});
284+
285+
// User clicks stop — this nulls the ref and calls stop()
286+
await act(async () => {
287+
fireEvent.click(screen.getByRole('button', { name: /stop recording/i }));
288+
});
289+
267290
expect(screen.getByRole('button', { name: /start recording/i })).toBeInTheDocument();
268291
});
269292
});

e2e/voice.spec.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
// Only run on Chromium-based browsers (Speech API support)
4+
test.describe('Voice transcription', () => {
5+
test.beforeEach(async ({ page, browserName }) => {
6+
test.skip(
7+
browserName !== 'chromium',
8+
'Speech Recognition only available in Chromium',
9+
);
10+
11+
// Grant microphone permission
12+
await page.context().grantPermissions(['microphone']);
13+
14+
// Inject a mock SpeechRecognition before the page loads
15+
await page.addInitScript(() => {
16+
class MockSpeechRecognition extends EventTarget {
17+
lang = '';
18+
interimResults = false;
19+
maxAlternatives = 1;
20+
onaudiostart: ((ev: Event) => void) | null = null;
21+
onresult: ((ev: Event) => void) | null = null;
22+
onend: ((ev: Event) => void) | null = null;
23+
onerror: ((ev: Event) => void) | null = null;
24+
25+
start() {
26+
// Fire audiostart after a short delay
27+
setTimeout(() => {
28+
this.onaudiostart?.(new Event('audiostart'));
29+
}, 50);
30+
31+
// Store on window so tests can trigger events
32+
(window as unknown as Record<string, unknown>).__mockRecognition = this;
33+
}
34+
35+
stop() {
36+
this.onend?.(new Event('end'));
37+
}
38+
39+
abort() {
40+
this.onend?.(new Event('end'));
41+
}
42+
}
43+
44+
(window as unknown as Record<string, unknown>).SpeechRecognition =
45+
MockSpeechRecognition;
46+
(window as unknown as Record<string, unknown>).webkitSpeechRecognition =
47+
MockSpeechRecognition;
48+
});
49+
50+
await page.goto('/');
51+
await page.waitForLoadState('networkidle');
52+
});
53+
54+
test('mic button exists and toggles listening state', async ({ page }) => {
55+
const micBtn = page.getByRole('button', { name: /start recording/i });
56+
await expect(micBtn).toBeVisible();
57+
58+
// Click to start recording
59+
await micBtn.click();
60+
61+
// Button should now say "Stop recording"
62+
const stopBtn = page.getByRole('button', { name: /stop recording/i });
63+
await expect(stopBtn).toBeVisible();
64+
65+
// Should show "Listening..." feedback
66+
await expect(page.getByText('Listening...')).toBeVisible();
67+
68+
// Click to stop
69+
await stopBtn.click();
70+
71+
// Should return to "Start recording"
72+
await expect(
73+
page.getByRole('button', { name: /start recording/i }),
74+
).toBeVisible();
75+
});
76+
77+
test('transcript appears in textarea after speech recognition', async ({
78+
page,
79+
}) => {
80+
const micBtn = page.getByRole('button', { name: /start recording/i });
81+
await micBtn.click();
82+
83+
// Wait for listening state
84+
await expect(
85+
page.getByRole('button', { name: /stop recording/i }),
86+
).toBeVisible();
87+
88+
// Simulate a speech recognition result from the mock
89+
await page.evaluate(() => {
90+
const rec = (
91+
window as unknown as Record<string, unknown>
92+
).__mockRecognition as {
93+
onresult: ((ev: unknown) => void) | null;
94+
};
95+
rec.onresult?.({
96+
resultIndex: 0,
97+
results: {
98+
0: { 0: { transcript: 'hello world' }, isFinal: true, length: 1 },
99+
length: 1,
100+
},
101+
});
102+
});
103+
104+
// Transcript should appear in the textarea
105+
const textarea = page.getByRole('textbox', { name: /note text/i });
106+
await expect(textarea).toHaveValue('hello world');
107+
});
108+
109+
test('multiple transcripts append with spaces', async ({ page }) => {
110+
// Type some initial text
111+
const textarea = page.getByRole('textbox', { name: /note text/i });
112+
await textarea.fill('existing note');
113+
114+
// Start listening
115+
await page.getByRole('button', { name: /start recording/i }).click();
116+
await expect(
117+
page.getByRole('button', { name: /stop recording/i }),
118+
).toBeVisible();
119+
120+
// Simulate first speech result
121+
await page.evaluate(() => {
122+
const rec = (
123+
window as unknown as Record<string, unknown>
124+
).__mockRecognition as {
125+
onresult: ((ev: unknown) => void) | null;
126+
};
127+
rec.onresult?.({
128+
resultIndex: 0,
129+
results: {
130+
0: {
131+
0: { transcript: 'plus voice' },
132+
isFinal: true,
133+
length: 1,
134+
},
135+
length: 1,
136+
},
137+
});
138+
});
139+
140+
await expect(textarea).toHaveValue('existing note plus voice');
141+
});
142+
143+
test('transcribed text can be saved', async ({ page }) => {
144+
const micBtn = page.getByRole('button', { name: /start recording/i });
145+
await micBtn.click();
146+
147+
await expect(
148+
page.getByRole('button', { name: /stop recording/i }),
149+
).toBeVisible();
150+
151+
// Simulate speech result
152+
await page.evaluate(() => {
153+
const rec = (
154+
window as unknown as Record<string, unknown>
155+
).__mockRecognition as {
156+
onresult: ((ev: unknown) => void) | null;
157+
};
158+
rec.onresult?.({
159+
resultIndex: 0,
160+
results: {
161+
0: {
162+
0: { transcript: 'save this note' },
163+
isFinal: true,
164+
length: 1,
165+
},
166+
length: 1,
167+
},
168+
});
169+
});
170+
171+
const textarea = page.getByRole('textbox', { name: /note text/i });
172+
await expect(textarea).toHaveValue('save this note');
173+
174+
// Stop recording first
175+
await page
176+
.getByRole('button', { name: /stop recording/i })
177+
.click();
178+
179+
// Save the note
180+
const saveBtn = page.getByRole('button', { name: /save/i });
181+
await expect(saveBtn).toBeEnabled();
182+
await saveBtn.click();
183+
184+
// Textarea should be cleared after save
185+
await expect(textarea).toHaveValue('');
186+
187+
// "Saved" confirmation should appear
188+
await expect(page.getByText('Saved')).toBeVisible();
189+
});
190+
191+
test('shows error when speech API is unsupported', async ({
192+
page,
193+
browserName,
194+
}) => {
195+
test.skip(
196+
browserName !== 'chromium',
197+
'Only testing on Chromium',
198+
);
199+
200+
// Create a fresh page without the mock
201+
const newPage = await page.context().newPage();
202+
await newPage.addInitScript(() => {
203+
delete (window as unknown as Record<string, unknown>)
204+
.SpeechRecognition;
205+
delete (window as unknown as Record<string, unknown>)
206+
.webkitSpeechRecognition;
207+
});
208+
await newPage.goto('/');
209+
await newPage.waitForLoadState('networkidle');
210+
211+
await newPage
212+
.getByRole('button', { name: /start recording/i })
213+
.click();
214+
215+
await expect(
216+
newPage.getByText('Speech API not supported'),
217+
).toBeVisible();
218+
219+
await newPage.close();
220+
});
221+
});

0 commit comments

Comments
 (0)