Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 58 additions & 57 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
],
"license": "",
"peerDependencies": {
"@vapi-ai/web": "^2.5.0",
"@vapi-ai/web": "^2.5.0 || ^3.0.0",
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
},
Expand All @@ -80,6 +80,7 @@
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vapi-ai/web": "^3.0.1",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
Expand Down
17 changes: 16 additions & 1 deletion src/hooks/useVapiCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ import Vapi from '@vapi-ai/web';
import * as vapiCallStorage from '../utils/vapiCallStorage';
import type { StorageType } from '../utils/vapiCallStorage';

// @vapi-ai/web emits these on 'error' but keeps the call running (3.x can emit
// the first three after the call has started), so they must not reset the
// widget to its idle state while the call and microphone are still live.
const NON_FATAL_ERROR_TYPES = new Set([
'audio-processing-setup-error',
'audio-processor-recovery-error',
'audio-start-failed',
'audio-observer-setup-error',
'video-recording-setup-error',
]);

export interface VapiCallState {
isCallActive: boolean;
isSpeaking: boolean;
Expand Down Expand Up @@ -134,8 +145,12 @@ export const useVapiCall = ({
callbacksRef.current.onMessage?.(message);
};

const handleError = (error: Error) => {
const handleError = (error: Error & { type?: string }) => {
console.error('Vapi error:', error);
if (error?.type && NON_FATAL_ERROR_TYPES.has(error.type)) {
callbacksRef.current.onError?.(error);
return;
}
setConnectionStatus('disconnected');
setIsCallActive(false);
setIsSpeaking(false);
Expand Down
97 changes: 97 additions & 0 deletions tests/widget-microphone.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { test, expect, type Page } from '@playwright/test';

// The embed bundles @vapi-ai/web, which asks for the microphone before it
// creates the call. Until the user allows it, no call may exist: the server
// would end one as assistant-did-not-receive-customer-audio.

// Full Chromium, because the headless shell rejects every getUserMedia call
// with NotSupportedError, so the allowed case could not pass there.
test.use({
channel: 'chromium',
launchOptions: { args: ['--use-fake-device-for-media-stream'] },
});

const WIDGET_PAGE = `<!DOCTYPE html>
<html>
<body>
<vapi-widget
public-key="pk_test"
assistant-id="asst_test"
api-url="http://localhost:4200/stub-api"
mode="voice"
cta-title="Open test widget"
start-button-text="Start test call"
></vapi-widget>
<script src="/dist/embed/widget.umd.js"></script>
</body>
</html>`;

const START_BUTTON = { name: 'Start test call' };

/** Opens the widget, clicks start, and returns a live count of POST /call/web. */
async function widgetCallStart(page: Page): Promise<() => number> {
let webCallRequests = 0;
await page.route('**/stub-api/call/web', async (route) => {
webCallRequests++;
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ message: 'stub: no real call' }),
});
});
await page.route('**/mic-test', (route) =>
route.fulfill({ contentType: 'text/html', body: WIDGET_PAGE })
);

await page.goto('/mic-test');
// The collapsed launcher is a clickable div, not a button; its label is
// set by this page, so the text is stable.
await page.getByText('Open test widget').click();
await page.getByRole('button', START_BUTTON).click();
return () => webCallRequests;
}

test.describe('VapiWidget microphone preflight', () => {
test('creates no call when the microphone is blocked', async ({ page }) => {
// Playwright can only grant permissions; denying needs the DevTools protocol.
// Without the context id it applies to the default context, not this test's.
const cdp = await page.context().newCDPSession(page);
const { targetInfo } = await cdp.send('Target.getTargetInfo');
await cdp.send('Browser.setPermission', {
permission: { name: 'microphone' },
setting: 'denied',
origin: 'http://localhost:4200',
browserContextId: targetInfo.browserContextId,
});

const webCallRequests = await widgetCallStart(page);

// The widget returns to its start state once start() fails.
await expect(page.getByRole('button', START_BUTTON)).toBeEnabled();
expect(webCallRequests()).toBe(0);
});

test('creates no call while the permission prompt is unanswered', async ({
page,
}) => {
// A click is a user gesture, so Chromium shows the prompt and waits.
const webCallRequests = await widgetCallStart(page);

await expect(
page.getByRole('button', { name: 'Connecting...' })
).toBeVisible();
await page.waitForTimeout(2000);
expect(webCallRequests()).toBe(0);
});

test('creates the call when the microphone is allowed', async ({
page,
context,
}) => {
await context.grantPermissions(['microphone']);

const webCallRequests = await widgetCallStart(page);

await expect.poll(webCallRequests).toBe(1);
});
});
Loading