Skip to content

Commit 7c6a2f9

Browse files
committed
fix: bundle @vapi-ai/web 3.0.1 so the widget asks for the microphone before creating a call
1 parent d929e08 commit 7c6a2f9

4 files changed

Lines changed: 173 additions & 59 deletions

File tree

‎package-lock.json‎

Lines changed: 58 additions & 57 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
],
6969
"license": "",
7070
"peerDependencies": {
71-
"@vapi-ai/web": "^2.5.0",
71+
"@vapi-ai/web": "^2.5.0 || ^3.0.0",
7272
"react": ">=16.8.0",
7373
"react-dom": ">=16.8.0"
7474
},
@@ -80,6 +80,7 @@
8080
"@types/react-dom": "^18.2.22",
8181
"@typescript-eslint/eslint-plugin": "^7.2.0",
8282
"@typescript-eslint/parser": "^7.2.0",
83+
"@vapi-ai/web": "^3.0.1",
8384
"@vitejs/plugin-react": "^4.3.0",
8485
"autoprefixer": "^10.4.19",
8586
"eslint": "^8.57.0",

‎src/hooks/useVapiCall.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,17 @@ import Vapi from '@vapi-ai/web';
33
import * as vapiCallStorage from '../utils/vapiCallStorage';
44
import type { StorageType } from '../utils/vapiCallStorage';
55

6+
// @vapi-ai/web emits these on 'error' but keeps the call running (3.x can emit
7+
// the first three after the call has started), so they must not reset the
8+
// widget to its idle state while the call and microphone are still live.
9+
const NON_FATAL_ERROR_TYPES = new Set([
10+
'audio-processing-setup-error',
11+
'audio-processor-recovery-error',
12+
'audio-start-failed',
13+
'audio-observer-setup-error',
14+
'video-recording-setup-error',
15+
]);
16+
617
export interface VapiCallState {
718
isCallActive: boolean;
819
isSpeaking: boolean;
@@ -134,8 +145,12 @@ export const useVapiCall = ({
134145
callbacksRef.current.onMessage?.(message);
135146
};
136147

137-
const handleError = (error: Error) => {
148+
const handleError = (error: Error & { type?: string }) => {
138149
console.error('Vapi error:', error);
150+
if (error?.type && NON_FATAL_ERROR_TYPES.has(error.type)) {
151+
callbacksRef.current.onError?.(error);
152+
return;
153+
}
139154
setConnectionStatus('disconnected');
140155
setIsCallActive(false);
141156
setIsSpeaking(false);

‎tests/widget-microphone.spec.ts‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { test, expect, type Page } from '@playwright/test';
2+
3+
// The embed bundles @vapi-ai/web, which asks for the microphone before it
4+
// creates the call. Until the user allows it, no call may exist: the server
5+
// would end one as assistant-did-not-receive-customer-audio.
6+
7+
// Full Chromium, because the headless shell rejects every getUserMedia call
8+
// with NotSupportedError, so the allowed case could not pass there.
9+
test.use({
10+
channel: 'chromium',
11+
launchOptions: { args: ['--use-fake-device-for-media-stream'] },
12+
});
13+
14+
const WIDGET_PAGE = `<!DOCTYPE html>
15+
<html>
16+
<body>
17+
<vapi-widget
18+
public-key="pk_test"
19+
assistant-id="asst_test"
20+
api-url="http://localhost:4200/stub-api"
21+
mode="voice"
22+
cta-title="Open test widget"
23+
start-button-text="Start test call"
24+
></vapi-widget>
25+
<script src="/dist/embed/widget.umd.js"></script>
26+
</body>
27+
</html>`;
28+
29+
const START_BUTTON = { name: 'Start test call' };
30+
31+
/** Opens the widget, clicks start, and returns a live count of POST /call/web. */
32+
async function widgetCallStart(page: Page): Promise<() => number> {
33+
let webCallRequests = 0;
34+
await page.route('**/stub-api/call/web', async (route) => {
35+
webCallRequests++;
36+
await route.fulfill({
37+
status: 400,
38+
contentType: 'application/json',
39+
body: JSON.stringify({ message: 'stub: no real call' }),
40+
});
41+
});
42+
await page.route('**/mic-test', (route) =>
43+
route.fulfill({ contentType: 'text/html', body: WIDGET_PAGE })
44+
);
45+
46+
await page.goto('/mic-test');
47+
// The collapsed launcher is a clickable div, not a button; its label is
48+
// set by this page, so the text is stable.
49+
await page.getByText('Open test widget').click();
50+
await page.getByRole('button', START_BUTTON).click();
51+
return () => webCallRequests;
52+
}
53+
54+
test.describe('VapiWidget microphone preflight', () => {
55+
test('creates no call when the microphone is blocked', async ({ page }) => {
56+
// Playwright can only grant permissions; denying needs the DevTools protocol.
57+
// Without the context id it applies to the default context, not this test's.
58+
const cdp = await page.context().newCDPSession(page);
59+
const { targetInfo } = await cdp.send('Target.getTargetInfo');
60+
await cdp.send('Browser.setPermission', {
61+
permission: { name: 'microphone' },
62+
setting: 'denied',
63+
origin: 'http://localhost:4200',
64+
browserContextId: targetInfo.browserContextId,
65+
});
66+
67+
const webCallRequests = await widgetCallStart(page);
68+
69+
// The widget returns to its start state once start() fails.
70+
await expect(page.getByRole('button', START_BUTTON)).toBeEnabled();
71+
expect(webCallRequests()).toBe(0);
72+
});
73+
74+
test('creates no call while the permission prompt is unanswered', async ({
75+
page,
76+
}) => {
77+
// A click is a user gesture, so Chromium shows the prompt and waits.
78+
const webCallRequests = await widgetCallStart(page);
79+
80+
await expect(
81+
page.getByRole('button', { name: 'Connecting...' })
82+
).toBeVisible();
83+
await page.waitForTimeout(2000);
84+
expect(webCallRequests()).toBe(0);
85+
});
86+
87+
test('creates the call when the microphone is allowed', async ({
88+
page,
89+
context,
90+
}) => {
91+
await context.grantPermissions(['microphone']);
92+
93+
const webCallRequests = await widgetCallStart(page);
94+
95+
await expect.poll(webCallRequests).toBe(1);
96+
});
97+
});

0 commit comments

Comments
 (0)