diff --git a/package-lock.json b/package-lock.json index 835c8b4..a466fbb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,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", @@ -43,7 +44,7 @@ "vite-plugin-dts": "^3.9.1" }, "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" } @@ -320,11 +321,11 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -378,11 +379,11 @@ } }, "node_modules/@daily-co/daily-js": { - "version": "0.84.0", - "resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.84.0.tgz", - "integrity": "sha512-/ynXrMDDkRXhLlHxiFNf9QU5yw4ZGPr56wNARgja/Tiid71UIniundTavCNF5cMb2I1vNoMh7oEJ/q8stg/V7g==", + "version": "0.94.0", + "resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.94.0.tgz", + "integrity": "sha512-xOWvaHPiphmZqIqvK4qmQude7bS1WhxfComd3SWRJCH5htjP8WJ3RrTfykchNwXZR8ziLy1Mr5XdzQ+KAyDHtw==", + "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "@sentry/browser": "^8.33.1", @@ -391,7 +392,7 @@ "events": "^3.1.0" }, "engines": { - "node": ">=10.0.0" + "node": ">=22.14.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1753,82 +1754,82 @@ } }, "node_modules/@sentry-internal/browser-utils": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz", - "integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.55.2.tgz", + "integrity": "sha512-GnKod+gL/Y+1FUM/RGV8q6le1CoyiGbT40MitEK7eVwWe+bfTRq1gN7ioupyHFMUg1RlQkDQ4/sENmio/uow5A==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sentry/core": "8.55.0" + "@sentry/core": "8.55.2" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/feedback": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.55.0.tgz", - "integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.55.2.tgz", + "integrity": "sha512-XQy//NWbL0mLLM5w8wNDWMNpXz39VUyW2397dUrH8++kR63WhUVAvTOtL0o0GMVadSAzl1b08oHP9zSUNFQwcg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sentry/core": "8.55.0" + "@sentry/core": "8.55.2" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.55.0.tgz", - "integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.55.2.tgz", + "integrity": "sha512-+W43Z697EVe/OgpGW07B773sa8xO1UbpnW0Cr+E+3FMDb6ZbXlaBUoagPTUkkQPdwBe35SDh6r8y2M3EOPGbxg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sentry-internal/browser-utils": "8.55.0", - "@sentry/core": "8.55.0" + "@sentry-internal/browser-utils": "8.55.2", + "@sentry/core": "8.55.2" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz", - "integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.55.2.tgz", + "integrity": "sha512-P/jGiuR7dRLG9IzD/463fLgiibyYceauav/9prRG0ZxJm1AtuO02OKball2Fs3bbzdzwHCTlcsUuL2ivDF4b5A==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sentry-internal/replay": "8.55.0", - "@sentry/core": "8.55.0" + "@sentry-internal/replay": "8.55.2", + "@sentry/core": "8.55.2" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/browser": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.55.0.tgz", - "integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.55.2.tgz", + "integrity": "sha512-xHuPIEKhx9zw5quWvv4YgZprnwoVMCfxIhmOIf6KJ9iizyUHeUDcKpLS59xERroqwX4RpvK+l/27AZu4zfZlzQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@sentry-internal/browser-utils": "8.55.0", - "@sentry-internal/feedback": "8.55.0", - "@sentry-internal/replay": "8.55.0", - "@sentry-internal/replay-canvas": "8.55.0", - "@sentry/core": "8.55.0" + "@sentry-internal/browser-utils": "8.55.2", + "@sentry-internal/feedback": "8.55.2", + "@sentry-internal/replay": "8.55.2", + "@sentry-internal/replay-canvas": "8.55.2", + "@sentry/core": "8.55.2" }, "engines": { "node": ">=14.18" } }, "node_modules/@sentry/core": { - "version": "8.55.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.0.tgz", - "integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==", + "version": "8.55.2", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.2.tgz", + "integrity": "sha512-YlEBwybUcOQ/KjMHDmof1vwweVnBtBxYlQp7DE3fOdtW4pqqdHWTnTntQs4VgYfxzjJYgtkd9LHlGtg8qy+JVQ==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.18" } @@ -2182,17 +2183,17 @@ "license": "ISC" }, "node_modules/@vapi-ai/web": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@vapi-ai/web/-/web-2.5.0.tgz", - "integrity": "sha512-I+XkLPG99kWspJ2AH4vky/vSgCui9ntb20n4/D9RSP6ChG3RHNvZVXoA79gKiY1fWmowuzBD7hL8sJbGHfJOJw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@vapi-ai/web/-/web-3.0.1.tgz", + "integrity": "sha512-ex8mT5PJFnDnU4Lg1wiIOIWhmdFCSOURLE2shKxsW5F7MDRbgnwCjh7uoYjl0bzOhaBhGnfjEQ5fCkkykettXA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@daily-co/daily-js": "^0.84.0", + "@daily-co/daily-js": "^0.94.0", "events": "^3.3.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.14.0" } }, "node_modules/@vitejs/plugin-react": { @@ -2559,11 +2560,11 @@ } }, "node_modules/bowser": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.12.1.tgz", - "integrity": "sha512-z4rE2Gxh7tvshQ4hluIT7XcFrgLIQaw9X3A+kTTRdovCz5PMukm/0QC/BKSYPj3omF5Qfypn9O/c5kgpmvYUCw==", - "license": "MIT", - "peer": true + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" }, "node_modules/boxen": { "version": "7.0.0", @@ -3535,8 +3536,8 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } diff --git a/package.json b/package.json index 0c63f80..2c8c550 100644 --- a/package.json +++ b/package.json @@ -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" }, @@ -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", diff --git a/src/hooks/useVapiCall.ts b/src/hooks/useVapiCall.ts index 73573eb..a65b619 100644 --- a/src/hooks/useVapiCall.ts +++ b/src/hooks/useVapiCall.ts @@ -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; @@ -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); diff --git a/tests/widget-microphone.spec.ts b/tests/widget-microphone.spec.ts new file mode 100644 index 0000000..c620334 --- /dev/null +++ b/tests/widget-microphone.spec.ts @@ -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 = ` + + + + + +`; + +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); + }); +});