@@ -16,6 +16,7 @@ import {
1616 type Page ,
1717 type Request ,
1818 type Route ,
19+ type WebSocketRoute ,
1920} from '@playwright/test'
2021import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
2122import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure'
@@ -524,8 +525,12 @@ try {
524525 await seed ( )
525526 }
526527 await checkNavigation ( 'A real Better Auth session reaches the extension iframe' , async ( ) => {
528+ const login = await openPage ( )
529+ await login . goto ( `${ base . origin } /login` )
527530 await signInAndSeed ( )
528- await shell . reload ( )
531+ await login . close ( )
532+ await shell . bringToFront ( )
533+ await frame ( ) . getByRole ( 'heading' , { name : 'Sign in to Sim' , exact : true } ) . click ( )
529534 await expect (
530535 frame ( ) . getByRole ( 'link' , { name : 'Extension QA alpha' , exact : true } )
531536 ) . toBeVisible ( )
@@ -1327,21 +1332,73 @@ try {
13271332 }
13281333 )
13291334 await checkComposer (
1330- 'The extension blocks microphone capture even when the Sim origin has permission ' ,
1335+ 'Voice input captures only on click, preserves drafts, and survives a rapid restart ' ,
13311336 async ( ) => {
1332- await browserContext . grantPermissions ( [ 'microphone' ] , { origin : base . origin } )
1333- const denied = await frame ( ) . evaluate ( async ( ) => {
1334- try {
1335- const stream = await navigator . mediaDevices . getUserMedia ( { audio : true } )
1336- stream . getTracks ( ) . forEach ( ( track ) => track . stop ( ) )
1337- return false
1338- } catch ( error ) {
1339- return error instanceof DOMException && error . name === 'NotAllowedError'
1340- }
1337+ const input = frame ( ) . getByRole ( 'textbox' , { name : 'Message' , exact : true } )
1338+ const mic = frame ( ) . getByRole ( 'button' , { name : 'Voice input' , exact : true } )
1339+ const stop = frame ( ) . getByRole ( 'button' , { name : 'Stop listening' , exact : true } )
1340+ const sessions : { socket : WebSocketRoute ; chunks : number ; closed : boolean } [ ] = [ ]
1341+ await shell . route ( '**/api/speech/token' , ( route ) => {
1342+ injectedRequests . add ( route . request ( ) )
1343+ return route . fulfill ( { json : { token : 'synthetic-extension-voice' } } )
13411344 } )
1342- assert ( denied , 'Microphone capture escaped the extension permissions policy' )
1343- await expect ( frame ( ) . getByRole ( 'button' , { name : 'Voice input' , exact : true } ) ) . toHaveCount ( 0 )
1344- await browserContext . clearPermissions ( )
1345+ await shell . routeWebSocket (
1346+ 'wss://api.elevenlabs.io/v1/speech-to-text/realtime*' ,
1347+ ( socket ) => {
1348+ const session = { socket, chunks : 0 , closed : false }
1349+ sessions . push ( session )
1350+ socket . onMessage ( ( message ) => {
1351+ const data = toRecord ( JSON . parse ( String ( message ) ) )
1352+ if ( typeof data . audio_base_64 === 'string' && data . audio_base_64 . length > 0 ) {
1353+ assert . equal ( data . sample_rate , 16000 )
1354+ assert ( Buffer . from ( data . audio_base_64 , 'base64' ) . length > 0 )
1355+ session . chunks ++
1356+ }
1357+ } )
1358+ socket . onClose ( ( ) => {
1359+ session . closed = true
1360+ } )
1361+ }
1362+ )
1363+ await browserContext . grantPermissions ( [ 'microphone' ] , { origin : base . origin } )
1364+ try {
1365+ await expect ( mic ) . toBeVisible ( )
1366+ await input . fill ( 'Keep my draft' )
1367+ assert . equal ( sessions . length , 0 , 'Voice started before an explicit click' )
1368+ await screenshot ( 'microphone-ready' )
1369+ await mic . click ( )
1370+ await expect ( stop ) . toBeVisible ( )
1371+ await expect . poll ( ( ) => sessions [ 0 ] ?. chunks ?? 0 ) . toBeGreaterThan ( 0 )
1372+ sessions [ 0 ] . socket . send (
1373+ JSON . stringify ( { message_type : 'partial_transcript' , text : 'first thought' } )
1374+ )
1375+ await expect ( input ) . toHaveValue ( 'Keep my draft first thought' )
1376+ await screenshot ( 'microphone-listening' )
1377+ await stop . click ( )
1378+ await expect ( mic ) . toBeVisible ( )
1379+ await mic . click ( )
1380+ await expect ( stop ) . toBeVisible ( )
1381+ await expect . poll ( ( ) => sessions . length ) . toBe ( 2 )
1382+ await expect . poll ( ( ) => sessions [ 0 ] . closed ) . toBe ( true )
1383+ await expect ( stop ) . toBeVisible ( )
1384+ const chunks = sessions [ 1 ] . chunks
1385+ await expect . poll ( ( ) => sessions [ 1 ] . chunks ) . toBeGreaterThan ( chunks )
1386+ sessions [ 0 ] . socket . send (
1387+ JSON . stringify ( { message_type : 'partial_transcript' , text : 'stale thought' } )
1388+ )
1389+ sessions [ 1 ] . socket . send (
1390+ JSON . stringify ( { message_type : 'partial_transcript' , text : 'second thought' } )
1391+ )
1392+ await expect ( input ) . toHaveValue ( 'Keep my draft first thought second thought' )
1393+ await stop . click ( )
1394+ await expect ( mic ) . toBeVisible ( )
1395+ await screenshot ( 'microphone-transcribed' )
1396+ await expect . poll ( ( ) => sessions [ 1 ] . closed ) . toBe ( true )
1397+ await input . fill ( '' )
1398+ } finally {
1399+ await shell . unroute ( '**/api/speech/token' )
1400+ await browserContext . clearPermissions ( )
1401+ }
13451402 }
13461403 )
13471404 await checkComposer (
0 commit comments