mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
fix(mobile): close the capture when the desktop start fails
The hook opens the microphone before it asks the desktop for a session, so a refused session left the mic open — and, since the screen rides the mic, the display held until the user cancelled, retried, or the screen unmounted. The failure arm now runs the same `rollbackRecordingStart` the commit failure does, because "undo the capture this start opened" is one thing and the hook owns it; guarded like that arm, so a seam that throws on the way down cannot take the desktop cancel with it. Red-first on both hosts. Natively, a new test drives the real seam under the engine and keep-awake mocks: the refusal used to leave `initialize` with no `toggleRecording(false)` and a held screen. On the page, the mic control's own test over the port pair saw `native.audio.start` with no `native.audio.stop`. Two unit cases pin the call itself, including for a start nobody will report. Ruling 36's own words: mic closed means released. This closes the mic rather than adding a release beside it. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
@@ -101,6 +101,49 @@ describe('startMobileDictationDesktopSession', () => {
|
||||
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes the capture the hook opened when the desktop start fails', async () => {
|
||||
const harness = createStartHarness({
|
||||
sendRequest: async (method) => {
|
||||
if (method === 'speech.dictation.start') {
|
||||
throw new Error('Desktop start failed')
|
||||
}
|
||||
return OK_RESPONSE
|
||||
}
|
||||
})
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
|
||||
'Desktop start failed'
|
||||
)
|
||||
|
||||
// The hook opened the microphone before this ran, and an open microphone holds the screen. No
|
||||
// session started, so both have to go back; nothing else on this path would end the capture,
|
||||
// and the hook's `start` has no catch to do it either.
|
||||
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
|
||||
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('closes it even when the failure is not the current start to report', async () => {
|
||||
let setNewerStart = () => undefined
|
||||
const harness = createStartHarness({
|
||||
sendRequest: async (method) => {
|
||||
if (method === 'speech.dictation.start') {
|
||||
setNewerStart()
|
||||
throw new Error('Desktop start failed')
|
||||
}
|
||||
return OK_RESPONSE
|
||||
}
|
||||
})
|
||||
setNewerStart = harness.setNewerStart
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
|
||||
|
||||
// A start nobody will hear about still opened a microphone, and the screen does not care
|
||||
// which generation held it.
|
||||
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not surface a desktop-start failure after the start became stale', async () => {
|
||||
let setNewerStart = () => undefined
|
||||
const harness = createStartHarness({
|
||||
|
||||
@@ -56,6 +56,17 @@ export async function startMobileDictationDesktopSession(
|
||||
dictationSessionStart.interpret(reply)
|
||||
} catch (err) {
|
||||
const wasCurrent = isCurrentStart(options)
|
||||
// The hook opened the capture before this ran, and an open microphone holds the screen. No
|
||||
// session started, so both go back — through the same rollback the commit failure below uses,
|
||||
// because "undo the capture this start opened" is one thing and the hook owns it. Before the
|
||||
// screen moved onto the mic this path leaked only an idle audio session; now it would pin the
|
||||
// display until the user cancelled, retried, or the screen unmounted.
|
||||
try {
|
||||
options.rollbackRecordingStart()
|
||||
} catch {
|
||||
// Guarded for the reason the commit arm below is: a seam that throws on the way down must
|
||||
// not take the desktop cancel with it, nor replace the failure the caller is about to see.
|
||||
}
|
||||
options.clearActiveId(dictationId)
|
||||
await dictationSessionCancel.request(client, { dictationId }).catch(() => undefined)
|
||||
// Awaited cleanup may overlap a newer start; stale work must not reset or
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* The native host, when the desktop refuses the session after the microphone is already open.
|
||||
*
|
||||
* The hook opens the capture first and asks the desktop for a session second, so by the time a
|
||||
* refusal comes back the device is holding a microphone and, since ruling 36, the screen with it.
|
||||
* Driven through the real native seam rather than a double, because what has to be given back are
|
||||
* device calls: this file is the only place the engine and `expo-keep-awake` answer for it.
|
||||
*/
|
||||
import { createElement } from 'react'
|
||||
import { act, create } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createFakeRpcClient, type FakeRpcClient } from '../mobile-web-shell/bridge-host-test-fakes'
|
||||
|
||||
const device = vi.hoisted(() => ({
|
||||
/** Every engine call, in order. */
|
||||
calls: new Array<string>(),
|
||||
/** The screen, as `+` and `-`: taken when the microphone opens, given back when it closes. */
|
||||
screen: new Array<string>()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active', addEventListener: () => ({ remove: () => {} }) },
|
||||
Platform: { OS: 'ios' }
|
||||
}))
|
||||
vi.mock('@orca/expo-two-way-audio', () => ({
|
||||
addExpoTwoWayAudioEventListener: () => ({ remove: () => {} }),
|
||||
initialize: () => {
|
||||
device.calls.push('initialize')
|
||||
return Promise.resolve(true)
|
||||
},
|
||||
requestMicrophonePermissionsAsync: () => Promise.resolve({ granted: true }),
|
||||
tearDown: () => device.calls.push('tearDown'),
|
||||
toggleRecording: (on: boolean) => {
|
||||
device.calls.push(`toggleRecording(${String(on)})`)
|
||||
return true
|
||||
}
|
||||
}))
|
||||
vi.mock('expo-keep-awake', () => ({
|
||||
activateKeepAwakeAsync: () => {
|
||||
device.screen.push('+')
|
||||
return Promise.resolve()
|
||||
},
|
||||
deactivateKeepAwake: () => {
|
||||
device.screen.push('-')
|
||||
return Promise.resolve()
|
||||
}
|
||||
}))
|
||||
|
||||
import { useMobileDictation, type UseMobileDictationResult } from './use-mobile-dictation'
|
||||
|
||||
const held: { dictation: UseMobileDictationResult | null } = { dictation: null }
|
||||
|
||||
function mount(client: FakeRpcClient): void {
|
||||
function Probe(): null {
|
||||
held.dictation = useMobileDictation({
|
||||
client,
|
||||
enabled: true,
|
||||
onTranscript: () => {},
|
||||
onError: () => {}
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
create(createElement(Probe))
|
||||
})
|
||||
}
|
||||
|
||||
function dictation(): UseMobileDictationResult {
|
||||
const current = held.dictation
|
||||
if (current === null) {
|
||||
throw new Error('nothing mounted')
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/** Answers every forwarded request, refusing the one the case names. */
|
||||
async function pump(rpc: FakeRpcClient, refuse: string): Promise<void> {
|
||||
for (let round = 0; round < 8; round += 1) {
|
||||
for (const request of rpc.requests.splice(0)) {
|
||||
request.resolve(
|
||||
request.method === refuse
|
||||
? { id: 'desktop', ok: false, error: { code: 'refused', message: 'no model installed' } }
|
||||
: { id: 'desktop', ok: true, result: {} }
|
||||
)
|
||||
}
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
device.calls.length = 0
|
||||
device.screen.length = 0
|
||||
held.dictation = null
|
||||
})
|
||||
|
||||
describe('a native start the desktop refuses', () => {
|
||||
it('gives the microphone and the screen back', async () => {
|
||||
const rpc = createFakeRpcClient()
|
||||
mount(rpc)
|
||||
await act(async () => {
|
||||
// The composer's own handler: a refused start is a toast, never a throw into render.
|
||||
const started = dictation()
|
||||
.start()
|
||||
.catch(() => undefined)
|
||||
await pump(rpc, 'speech.dictation.start')
|
||||
await started
|
||||
})
|
||||
// The engine came up before the desktop was asked, so it has to go back down.
|
||||
expect(device.calls).toContain('initialize')
|
||||
expect(device.calls).toContain('toggleRecording(false)')
|
||||
// And the screen with it: nothing else on this path would release it, and the hook's `start`
|
||||
// has no catch of its own.
|
||||
expect(device.screen).toEqual(['+', '-'])
|
||||
expect(dictation().status).toBe('idle')
|
||||
})
|
||||
|
||||
it('holds nothing when the refusal comes before the microphone opens', async () => {
|
||||
const rpc = createFakeRpcClient()
|
||||
mount(rpc)
|
||||
await act(async () => {
|
||||
await dictation().cancel()
|
||||
})
|
||||
// No start, so no open: the screen was never taken. Read as an absence of holds rather than an
|
||||
// empty list, because the lock is one module-level owner shared by both device captures and a
|
||||
// release the previous case's cleanup issued would land in this one's list.
|
||||
expect(device.screen).not.toContain('+')
|
||||
expect(device.calls).not.toContain('initialize')
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,9 @@ import type { BridgeNativeVerb } from '../mobile-web-shell/bridge/bridge-native-
|
||||
/** Every message that reached the composer's own error handler, which is what it toasts. */
|
||||
const reported: string[] = []
|
||||
|
||||
/** Every hold and release the shell's capture asked the device for, as `+` and `-`. */
|
||||
const screen: string[] = []
|
||||
|
||||
/** The three verbs served by the real handlers over an engine that opens and produces no audio. */
|
||||
function createAudioShell(): (verb: BridgeNativeVerb, params: unknown) => Promise<unknown> {
|
||||
const engine: NativeAudioEngine = {
|
||||
@@ -68,7 +71,10 @@ function createAudioShell(): (verb: BridgeNativeVerb, params: unknown) => Promis
|
||||
end: () => {},
|
||||
onMicrophoneData: () => ({ remove: () => {} }),
|
||||
onInterruption: () => ({ remove: () => {} }),
|
||||
screenLock: { hold: () => {}, release: () => {} }
|
||||
screenLock: {
|
||||
hold: () => screen.push('+'),
|
||||
release: () => screen.push('-')
|
||||
}
|
||||
}
|
||||
const capture = createNativeAudioCapture(engine)
|
||||
return (verb, params) => capture.serve(verb, params)
|
||||
@@ -108,9 +114,19 @@ function Composer({ pair }: { pair: BridgePortPair }): ReactElement {
|
||||
)
|
||||
}
|
||||
|
||||
/** What the desktop answers a forwarded request with; the default is a plain success. */
|
||||
type DesktopAnswer = (method: string) => {
|
||||
id: string
|
||||
ok: boolean
|
||||
result?: unknown
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
const DESKTOP_OK: DesktopAnswer = () => ({ id: 'desktop', ok: true, result: {} })
|
||||
|
||||
type MicControl = {
|
||||
readonly label: () => string
|
||||
readonly tap: () => Promise<void>
|
||||
readonly tap: (answer?: DesktopAnswer) => Promise<void>
|
||||
}
|
||||
|
||||
/** The mic button, found by the label it carries in every state rather than by position. */
|
||||
@@ -144,7 +160,7 @@ async function mount(pair: BridgePortPair): Promise<MicControl> {
|
||||
}
|
||||
return {
|
||||
label: () => String(micOf(rendered.root).props.accessibilityLabel),
|
||||
tap: async () => {
|
||||
tap: async (answer: DesktopAnswer = DESKTOP_OK) => {
|
||||
await act(async () => {
|
||||
micOf(rendered.root).props.onPress()
|
||||
})
|
||||
@@ -153,7 +169,8 @@ async function mount(pair: BridgePortPair): Promise<MicControl> {
|
||||
await act(async () => {
|
||||
await pair.flush()
|
||||
for (const request of pair.rpc.requests.splice(0)) {
|
||||
request.resolve({ id: 'desktop', ok: true, result: {} })
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake client takes the reply shape the host would have sent, which is what a case builds here.
|
||||
request.resolve(answer(request.method) as never)
|
||||
}
|
||||
await pair.flush()
|
||||
})
|
||||
@@ -164,6 +181,7 @@ async function mount(pair: BridgePortPair): Promise<MicControl> {
|
||||
|
||||
beforeEach(() => {
|
||||
reported.length = 0
|
||||
screen.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -200,6 +218,38 @@ describe('the mic control on a page the shell did not grant audio', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('a page whose desktop start fails after the microphone opened', () => {
|
||||
/**
|
||||
* The shell has the microphone open by the time the desktop refuses the session: `open()` is
|
||||
* `native.audio.start`, and the page only asks the desktop for a session afterwards. Nothing on
|
||||
* that path used to end the capture, so the screen the shell took stayed held until the user
|
||||
* cancelled or the screen unmounted.
|
||||
*/
|
||||
it('closes the shell capture and gives the screen back', async () => {
|
||||
const shell = createAudioShell()
|
||||
const verbs: string[] = []
|
||||
const pair = createFakeBridgePortPair({
|
||||
serveNativeVerb: (verb, params) => {
|
||||
verbs.push(verb)
|
||||
return shell(verb, params)
|
||||
}
|
||||
})
|
||||
const mic = await mount(pair)
|
||||
await mic.tap((method) =>
|
||||
method === 'speech.dictation.start'
|
||||
? { id: 'desktop', ok: false, error: { code: 'refused', message: 'no model installed' } }
|
||||
: { id: 'desktop', ok: true, result: {} }
|
||||
)
|
||||
expect(verbs).toContain('native.audio.start')
|
||||
// The microphone is closed and the screen is back, both because the capture ended.
|
||||
expect(verbs).toContain('native.audio.stop')
|
||||
expect(screen).toEqual(['+', '-'])
|
||||
// And the user is told, rather than left looking at a live mic button.
|
||||
expect(reported).not.toEqual([])
|
||||
expect(mic.label()).toBe('Start voice dictation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the mic control on a page the shell did grant audio', () => {
|
||||
it('reaches recording, with no refusal reported', async () => {
|
||||
const pair = createFakeBridgePortPair({ serveNativeVerb: createAudioShell() })
|
||||
|
||||
Reference in New Issue
Block a user