Skip to content

Commit e4fe115

Browse files
committed
test(extension): exercise voice restart and sign-in recovery
1 parent 3f8b333 commit e4fe115

2 files changed

Lines changed: 72 additions & 15 deletions

File tree

‎apps/extension/src/sidepanel.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,7 @@ function SidePanel({ origin }: SidePanelProps) {
413413
)}
414414
</div>
415415
<iframe
416-
allow={`clipboard-write ${origin}`}
416+
allow={`clipboard-write ${origin}; microphone ${origin}`}
417417
ref={frameRef}
418418
title='Sim chat'
419419
src={`${origin}/extension`}

‎apps/sim/scripts/test-browser-extension-e2e.ts‎

Lines changed: 71 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type Page,
1717
type Request,
1818
type Route,
19+
type WebSocketRoute,
1920
} from '@playwright/test'
2021
import { pageSnapshotSchema } from '@sim/browser-extension-protocol'
2122
import { 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

Comments
 (0)