@@ -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 : / ^ ( V o i c e i n p u t | S t o p l i s t e n i n g ) $ / } )
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