@@ -21,6 +21,7 @@ import {
2121import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
2222import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
2323import { createLogger } from '@sim/logger'
24+ import { createDeferred } from '@sim/testing/helpers/deferred'
2425import { toNumberOrNull, toStringOrNull } from '@sim/utils/coerce'
2526import { getErrorMessage } from '@sim/utils/errors'
2627import { generateId } from '@sim/utils/id'
@@ -1416,19 +1417,27 @@ try {
14161417 }
14171418 )
14181419 await checkComposer(
1419- 'Voice input recovers permission, preserves drafts , and stops on Send or restart',
1420+ 'Voice input cancels startup, recovers permission , and preserves drafts through Send or restart',
14201421 async () => {
14211422 const input = frame().getByRole('textbox', { name: 'Message', exact: true })
14221423 const mic = frame().getByRole('button', { name: 'Voice input', exact: true })
14231424 const stop = frame().getByRole('button', { name: 'Stop listening', exact: true })
14241425 const sessions: { socket: WebSocketRoute; chunks: number; closed: boolean }[] = []
1425- await shell.route('**/api/speech/token', (route) => {
1426+ const connectionGate = createDeferred<void>()
1427+ const tokenGate = createDeferred<void>()
1428+ let holdToken = false
1429+ let tokenRequested = false
1430+ await shell.route('**/api/speech/token', async (route) => {
14261431 injectedRequests.add(route.request())
1432+ if (holdToken) {
1433+ tokenRequested = true
1434+ await tokenGate.promise
1435+ }
14271436 return route.fulfill({ json: { token: 'synthetic-extension-voice' } })
14281437 })
14291438 await shell.routeWebSocket(
14301439 'wss://api.elevenlabs.io/v1/speech-to-text/realtime*',
1431- (socket) => {
1440+ async (socket) => {
14321441 const session = { socket, chunks: 0, closed: false }
14331442 sessions.push(session)
14341443 socket.onMessage((message) => {
@@ -1443,8 +1452,45 @@ try {
14431452 await socket.close()
14441453 session.closed = true
14451454 })
1455+ if (sessions.length === 1) await connectionGate.promise
14461456 }
14471457 )
1458+ type VoiceCaptureWindow = typeof window & {
1459+ __voiceCaptureProbe?: {
1460+ getUserMedia: MediaDevices['getUserMedia']
1461+ WebSocket: typeof WebSocket
1462+ tracks: MediaStreamTrack[]
1463+ sockets: WebSocket[]
1464+ }
1465+ }
1466+ await frame().evaluate(() => {
1467+ const probe = {
1468+ getUserMedia: navigator.mediaDevices.getUserMedia,
1469+ WebSocket: window.WebSocket,
1470+ tracks: [] as MediaStreamTrack[],
1471+ sockets: [] as WebSocket[],
1472+ }
1473+ ;(window as VoiceCaptureWindow).__voiceCaptureProbe = probe
1474+ navigator.mediaDevices.getUserMedia = async (constraints) => {
1475+ const stream = await probe.getUserMedia.call(navigator.mediaDevices, constraints)
1476+ probe.tracks.push(...stream.getTracks())
1477+ return stream
1478+ }
1479+ window.WebSocket = class extends probe.WebSocket {
1480+ constructor(url: string | URL, protocols?: string | string[]) {
1481+ super(url, protocols)
1482+ probe.sockets.push(this)
1483+ }
1484+ }
1485+ })
1486+ const captureState = () =>
1487+ frame().evaluate(() => {
1488+ const probe = (window as VoiceCaptureWindow).__voiceCaptureProbe
1489+ return {
1490+ tracks: probe?.tracks.map((track) => track.readyState) ?? [],
1491+ sockets: probe?.sockets.map((socket) => socket.readyState) ?? [],
1492+ }
1493+ })
14481494 const microphone = await browserContext.newCDPSession(shell)
14491495 const setMicrophonePermission = async (setting: 'granted' | 'denied') => {
14501496 await microphone.send('Browser.setPermission', {
@@ -1490,9 +1536,34 @@ try {
14901536 await setPanelViewport(viewport.width, viewport.height)
14911537 await setMicrophonePermission('granted')
14921538 await mic.click()
1539+ await expect.poll(() => sessions.length).toBe(1)
1540+ await expect.poll(captureState).toEqual({ tracks: ['live'], sockets: [0] })
1541+ await screenshot('microphone-connecting')
1542+ await frame()
1543+ .getByRole('button', { name: /^(Voice input|Stop listening)$/ })
1544+ .click()
1545+ await expect.poll(captureState).toEqual({ tracks: ['ended'], sockets: [3] })
1546+ await expect.poll(() => sessions[0].closed).toBe(true)
1547+ voiceDiagnostics.push({ event: 'cancel-connecting', ...(await captureState()) })
1548+ connectionGate.resolve()
1549+ await expect(mic).toBeVisible()
1550+ await expect(input).toHaveValue('Keep my draft')
1551+ assert.equal(sessions[0].chunks, 0, 'Cancelled startup sent audio')
1552+
1553+ holdToken = true
1554+ await mic.click()
1555+ await expect.poll(() => tokenRequested).toBe(true)
1556+ await stop.click()
1557+ tokenGate.resolve()
1558+ holdToken = false
1559+ await expect(mic).toBeVisible()
1560+ await expect(input).toHaveValue('Keep my draft')
1561+ await mic.click()
14931562 await expect(stop).toBeVisible()
1494- await expect.poll(() => sessions[0]?.chunks ?? 0).toBeGreaterThan(0)
1495- sessions[0].socket.send(
1563+ await expect.poll(() => sessions[1]?.chunks ?? 0).toBeGreaterThan(0)
1564+ assert.equal(sessions.length, 2, 'Cancelled token request started another session')
1565+ assert.deepEqual((await captureState()).tracks, ['ended', 'live'])
1566+ sessions[1].socket.send(
14961567 JSON.stringify({ message_type: 'partial_transcript', text: 'first thought' })
14971568 )
14981569 await expect(input).toHaveValue('Keep my draft first thought')
@@ -1501,16 +1572,16 @@ try {
15011572 await expect(mic).toBeVisible()
15021573 await mic.click()
15031574 await expect(stop).toBeVisible()
1504- await expect.poll(() => sessions.length).toBe(2 )
1505- sessions[0 ].socket.send(
1575+ await expect.poll(() => sessions.length).toBe(3 )
1576+ sessions[1 ].socket.send(
15061577 JSON.stringify({ message_type: 'partial_transcript', text: 'stale thought' })
15071578 )
1508- await expect.poll(() => sessions[0 ].closed).toBe(true)
1579+ await expect.poll(() => sessions[1 ].closed).toBe(true)
15091580 await expect(stop).toBeVisible()
15101581 await expect(input).toHaveValue('Keep my draft first thought')
1511- const chunks = sessions[1 ].chunks
1512- await expect.poll(() => sessions[1 ].chunks).toBeGreaterThan(chunks)
1513- sessions[1 ].socket.send(
1582+ const chunks = sessions[2 ].chunks
1583+ await expect.poll(() => sessions[2 ].chunks).toBeGreaterThan(chunks)
1584+ sessions[2 ].socket.send(
15141585 JSON.stringify({ message_type: 'partial_transcript', text: 'second thought' })
15151586 )
15161587 await expect(input).toHaveValue('Keep my draft first thought second thought')
@@ -1519,31 +1590,43 @@ try {
15191590 assert.equal(submitted[0].message, 'Keep my draft first thought second thought')
15201591 await expect(input).toHaveValue('')
15211592 await expect(mic).toBeVisible()
1522- await expect.poll(() => sessions[1 ].closed).toBe(true)
1593+ await expect.poll(() => sessions[2 ].closed).toBe(true)
15231594 await expect(
15241595 frame().getByRole('button', { name: 'Send message', exact: true })
15251596 ).toBeVisible()
15261597 await input.fill('Another draft')
15271598 await mic.click()
15281599 await expect(stop).toBeVisible()
1529- await expect.poll(() => sessions[2 ]?.chunks ?? 0).toBeGreaterThan(0)
1530- sessions[2 ].socket.send(
1600+ await expect.poll(() => sessions[3 ]?.chunks ?? 0).toBeGreaterThan(0)
1601+ sessions[3 ].socket.send(
15311602 JSON.stringify({ message_type: 'partial_transcript', text: 'ready to send' })
15321603 )
15331604 await expect(input).toHaveValue('Another draft ready to send')
15341605 await screenshot('microphone-dictated-draft')
15351606 await stop.click()
15361607 await input.press('Enter')
15371608 await expect(input).toHaveValue('')
1538- assert.equal(sessions[2 ].closed, false, 'Final-transcript grace must still be open')
1539- sessions[2 ].socket.send(
1609+ assert.equal(sessions[3 ].closed, false, 'Final-transcript grace must still be open')
1610+ sessions[3 ].socket.send(
15401611 JSON.stringify({ message_type: 'partial_transcript', text: 'late words' })
15411612 )
1542- await expect.poll(() => sessions[2 ].closed).toBe(true)
1613+ await expect.poll(() => sessions[3 ].closed).toBe(true)
15431614 await expect(input).toHaveValue('')
15441615 assert.equal(submitted.length, 2, 'Dictation submitted a message without Send')
15451616 await screenshot('microphone-after-send')
15461617 } finally {
1618+ voiceDiagnostics.push({ event: 'capture-finally', ...(await captureState()) })
1619+ connectionGate.resolve()
1620+ tokenGate.resolve()
1621+ await frame().evaluate(() => {
1622+ const probeWindow = window as VoiceCaptureWindow
1623+ const probe = probeWindow.__voiceCaptureProbe
1624+ if (!probe) return
1625+ navigator.mediaDevices.getUserMedia = probe.getUserMedia
1626+ window.WebSocket = probe.WebSocket
1627+ probe.tracks.forEach((track) => track.stop())
1628+ probeWindow.__voiceCaptureProbe = undefined
1629+ })
15471630 voiceDiagnostics.push({
15481631 event: 'finally',
15491632 sessions: sessions.map(({ chunks, closed }) => ({ chunks, closed })),
0 commit comments