Enable accessibility tree (ax) command on iOS emulator sessions (#10007)

* Enable accessibility tree (`ax`) command on iOS emulator sessions

Fetch the accessibility tree from serve-sim's /ax endpoint, which requires an
active session but provides the same UI snapshot capability as Android's
uiautomator output. Derive the endpoint from the stream URL when not explicitly
provided by the helper, and route through the bridge to pass session context to
the backend.

* Add ax command routing and backend integration tests

Tests verify accessibility tree routes through EmulatorBridge,
Android backend ignores iOS-specific ax URLs, and ax endpoints
are derived from serve-sim stream URLs.
This commit is contained in:
Jinjing
2026-07-22 17:10:54 -07:00
committed by GitHub
parent 01bcc57ff6
commit 43ae014a64
16 changed files with 557 additions and 21 deletions
+3 -2
View File
@@ -117,8 +117,9 @@ Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
not. For unicode-heavy input, use the app UI directly.
- `gesture` is a straight swipe between the first and last point (adb limitation);
fine for scroll/swipe, not for true multi-touch paths.
- Capability verbs (`install/launch/permissions/ax/logcat`) are **Android-only**;
running them against an iOS device fails with `emulator_unsupported`.
- Capability verbs `install/launch/permissions/logcat` are **Android-only** and
fail against an iOS device with `emulator_unsupported`. `ax` works on both,
with backend-specific output (uiautomator tree vs serve-sim AX snapshot).
- No camera/sensor injection yet.
## Targeting devices & worktrees
+1 -1
View File
@@ -99,7 +99,7 @@ Use `--json` for agent-friendly output. Commands are workspace-scoped by default
| Rotate device | `ORCA emulator rotate landscape_left` | Remembers orientation for subsequent gestures. |
| Camera injection | `ORCA emulator camera com.acme.App --webcam` | Or --file, placeholder. Hot-swap with switch. May (re)launch app. |
| Permissions | `ORCA emulator permissions grant camera com.acme.App` | grant/revoke/reset/list. See full subcommand help. |
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Or via exec for raw endpoint. |
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX snapshot (screen + elements). Needs an active session. |
| Raw / advanced | `ORCA emulator exec --command "tap 0.5 0.7"` | Or "ca-debug blended on", "memory-warning", full serve-sim subcommands (no "serve-sim" prefix needed in the command string). Bridge injects active device context. |
| Stop | `ORCA emulator kill [--device <id>]` | Or let pane close / Orca quit clean up. |
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -108,7 +108,7 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['emulator', 'ax'],
summary: 'Dump the Android accessibility (uiautomator) tree',
summary: 'Dump the device accessibility tree (uiautomator on Android, serve-sim AX on iOS)',
usage: 'orca emulator ax [--device <id>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'device', 'emulator', 'worktree']
},
@@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'
import { AndroidEmulatorBackend } from './android-emulator-backend'
import type { AndroidCommandResult, AndroidCommandRunner } from '../android/android-command-runner'
import type { AndroidSdkPaths } from '../android/android-sdk-discovery'
import type { EmulatorBackend } from './emulator-backend'
// The AVD boot spawns the emulator detached (not via the command runner).
vi.mock('node:child_process', async (importOriginal) => {
@@ -219,6 +220,26 @@ describe('AndroidEmulatorBackend', () => {
expect(tree.children[0]).toMatchObject({ text: 'Hi' })
})
it('ignores the ios ax endpoint argument and still dumps via adb', async () => {
runner.mockImplementation(async (binary: string, args: readonly string[]) => {
const a = args.join(' ')
if (binary === SDK.adb && a === 'devices -l') {
return ok(RUNNING_ADB)
}
if (binary === SDK.adb && a === '-s emulator-5554 shell cat /sdcard/window_dump.xml') {
return ok('<hierarchy><node text="Hi"/></hierarchy>')
}
return ok('')
})
// The widened EmulatorBackend.accessibilityTree(deviceId, axUrl) hands Android an
// iOS-only ax URL through the interface; Android must drop it and dump via adb.
const iface: EmulatorBackend = backend(runner)
const tree = (await iface.accessibilityTree!('emulator-5554', 'http://127.0.0.1:3100/ax')) as {
children: { text?: string }[]
}
expect(tree.children[0]).toMatchObject({ text: 'Hi' })
})
it('boots a shutdown AVD and waits for the new booted serial', async () => {
let bootStarted = false
vi.mocked(spawn).mockImplementation(() => {
@@ -80,8 +80,8 @@ export type EmulatorBackend = {
rotate(deviceId: string, orientation: string): Promise<void>
exec(deviceId: string, command: string): Promise<unknown>
// Capability-gated verbs (Android today). The router checks `capabilities`
// before calling these and rejects unsupported backends with emulator_unsupported.
// Capability-gated verbs. The router checks `capabilities` before calling
// these and rejects unsupported backends with emulator_unsupported.
installApp?(deviceId: string, apkPath: string, options?: { reinstall?: boolean }): Promise<void>
launchApp?(deviceId: string, packageName: string, activity?: string): Promise<void>
setPermission?(
@@ -90,7 +90,9 @@ export type EmulatorBackend = {
packageName: string,
permission?: string
): Promise<void>
accessibilityTree?(deviceId: string): Promise<unknown>
// axUrl is the session's serve-sim /ax endpoint from the registry; Android
// dumps via adb from the device serial and ignores it.
accessibilityTree?(deviceId: string, axUrl: string | null): Promise<unknown>
logcat?(
deviceId: string,
options?: { lines?: number; filters?: readonly string[] }
@@ -83,7 +83,7 @@ describe('IosEmulatorBackend', () => {
parseServeSimDetachedSessionMock.mockReset()
})
it('declares ios kind, mjpeg codec, and no explicit-verb capabilities', () => {
it('declares ios kind, mjpeg codec, and only the ax explicit-verb capability', () => {
const backend = new IosEmulatorBackend()
expect(backend.kind).toBe('ios')
expect(backend.streamCodec).toBe('mjpeg')
@@ -91,11 +91,23 @@ describe('IosEmulatorBackend', () => {
install: false,
launch: false,
permissions: false,
accessibilityTree: false,
accessibilityTree: true,
logcat: false
})
})
it('fetches the accessibility tree from the session ax endpoint and rejects without one', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const backend = new IosEmulatorBackend({ fetchAccessibilityTree })
await expect(
backend.accessibilityTree('device-1', 'http://127.0.0.1:3100/ax')
).resolves.toEqual({ elements: [] })
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/ax')
await expect(backend.accessibilityTree('device-1', null)).rejects.toMatchObject({
code: 'emulator_no_active'
})
})
it('taps via serve-sim with the resolved device', async () => {
const backend = new IosEmulatorBackend()
await backend.tap('iPhone 16 Pro', 0.5, 0.7)
@@ -23,6 +23,7 @@ import {
import type { EmulatorBridgeOptions } from '../emulator-bridge-types'
import { sendEmulatorGestureSequence, type EmulatorGesturePoint } from '../emulator-gesture-sender'
import { parseServeSimDetachedSession } from '../serve-sim-detached-session'
import { fetchServeSimAccessibilityTree, type FetchAccessibilityTree } from '../serve-sim-ax-tree'
import { hideNativeSimulatorApp } from '../simulator-app-visibility'
import type {
BackendAvailability,
@@ -37,20 +38,23 @@ import type {
export class IosEmulatorBackend implements EmulatorBackend {
readonly kind = 'ios' as const
readonly streamCodec = 'mjpeg' as const
// iOS exposes ax/permissions/etc. via `exec`; explicit verbs are Android-only for v1.
// iOS exposes install/launch/permissions/logcat via `exec`; ax has an explicit
// verb backed by the active session's serve-sim /ax endpoint.
readonly capabilities: EmulatorBackendCapabilities = {
install: false,
launch: false,
permissions: false,
accessibilityTree: false,
accessibilityTree: true,
logcat: false
}
private cachedServeSimExecutable: ServeSimExecutable | undefined
private readonly waitForEndpointReady: (endpoint: string) => Promise<boolean>
private readonly fetchAccessibilityTree: FetchAccessibilityTree
constructor(options: EmulatorBridgeOptions = {}) {
this.waitForEndpointReady = options.waitForEndpointReady ?? waitForServeSimEndpointReady
this.fetchAccessibilityTree = options.fetchAccessibilityTree ?? fetchServeSimAccessibilityTree
}
// Why: resolving the executable can materialize the serve-sim runtime (a one-time
@@ -170,6 +174,18 @@ export class IosEmulatorBackend implements EmulatorBackend {
await this.execServeSim(['rotate', orientation, '-d', udid])
}
// Mirrors gesture: the tree comes from the active session's helper endpoint,
// so without a session there is nothing to query.
async accessibilityTree(_deviceId: string, axUrl: string | null): Promise<unknown> {
if (!axUrl) {
throw new EmulatorError(
'emulator_no_active',
'No active emulator session for the accessibility tree. Start one first.'
)
}
return this.fetchAccessibilityTree(axUrl)
}
async exec(deviceId: string, command: string): Promise<unknown> {
const udid = await this.resolveDeviceId(deviceId)
const rawArgs = stripEmulatorTargetArgs(parseServeSimCommandArgs(command.trim()))
@@ -14,4 +14,5 @@ export type EmulatorSessionState = {
export type EmulatorBridgeOptions = {
waitForEndpointReady?: (endpoint: string) => Promise<boolean>
fetchAccessibilityTree?: (axUrl: string) => Promise<unknown>
}
+63 -1
View File
@@ -184,12 +184,51 @@ describe('EmulatorBridge helper ownership', () => {
it('rejects a capability the resolved backend does not support', async () => {
const bridge = new EmulatorBridge()
// device-1 resolves to the iOS backend, which advertises no explicit-verb caps.
// device-1 resolves to the iOS backend, which does not advertise install.
await expect(
bridge.runCapability('install', { device: 'device-1' }, async () => 'unused')
).rejects.toMatchObject({ code: 'emulator_unsupported' })
})
it('routes ax to the active session ax endpoint on iOS', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
axUrl: 'http://127.0.0.1:3100/device-1/ax'
})
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).resolves.toEqual({
elements: []
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/device-1/ax')
})
it('derives the ax endpoint for sessions registered without axUrl', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
// e.g. renderer-supplied session info that predates ax derivation.
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg'
})
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).resolves.toEqual({
elements: []
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/ax')
})
it('rejects ax when the session has no ax endpoint and none can be derived', async () => {
const bridge = new EmulatorBridge()
// session() streamUrl has no mjpeg suffix, so no /ax endpoint can be inferred.
bridge.registerActiveEmulator('wt-1', session('device-1'))
await expect(bridge.accessibilityTree({ worktreeId: 'wt-1' })).rejects.toMatchObject({
code: 'emulator_no_active'
})
})
it('kills the helper and shuts down the selected simulator', async () => {
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
@@ -381,6 +420,29 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
})
})
it('routes emulatorAx through the bridge to the active session ax endpoint', async () => {
const fetchAccessibilityTree = vi.fn(async () => ({ elements: [{ label: 'Login' }] }))
const bridge = new EmulatorBridge({ fetchAccessibilityTree })
bridge.registerActiveEmulator('wt-1', {
...session('device-1'),
axUrl: 'http://127.0.0.1:3100/device-1/ax'
})
const commands = new RuntimeEmulatorCommands({
getEmulatorBridge: () => bridge,
resolveWorktreeSelector: vi.fn(async () => ({ id: 'wt-1' })),
getAuthoritativeWindow: () => ({ webContents: { send: vi.fn() } }) as never,
getSettings: () => ({
mobileEmulatorEnabled: true,
mobileEmulatorDefaultDeviceUdid: null
})
})
await expect(commands.emulatorAx({ worktree: 'wt-1' })).resolves.toEqual({
elements: [{ label: 'Login' }]
})
expect(fetchAccessibilityTree).toHaveBeenCalledWith('http://127.0.0.1:3100/device-1/ax')
})
it('rejects attach when mobile emulator is disabled', async () => {
const bridge = new EmulatorBridge()
const commands = new RuntimeEmulatorCommands({
+12
View File
@@ -5,6 +5,7 @@ import type { SimulatorDevice } from './simctl-simulator-devices'
import type { EmulatorBridgeOptions } from './emulator-bridge-types'
import type { EmulatorGesturePoint } from './emulator-gesture-sender'
import { EmulatorSessionRegistry } from './emulator-session-registry'
import { deriveServeSimAxUrl } from './serve-sim-detached-session'
import { IosEmulatorBackend } from './backends/ios-emulator-backend'
import { AndroidEmulatorBackend } from './backends/android-emulator-backend'
import type {
@@ -199,6 +200,17 @@ export class EmulatorBridge {
return backend.exec(device, command)
}
async accessibilityTree(opts?: EmulatorTargetOpts): Promise<unknown> {
return this.runCapability('accessibilityTree', opts, async (backend, device) => {
const udid = await backend.resolveDeviceId(device)
const session = this.sessionRegistry.getSession(udid)
// Fallback heals sessions registered without axUrl (e.g. renderer-supplied
// info that predates ax derivation); Android backends ignore the argument.
const axUrl = session?.axUrl ?? deriveServeSimAxUrl(session?.streamUrl) ?? null
return backend.accessibilityTree!(udid, axUrl)
})
}
// Runs a capability-gated verb against the resolved target, rejecting backends
// that do not advertise the capability (e.g. install/logcat on iOS).
async runCapability<T>(
+175
View File
@@ -0,0 +1,175 @@
import { describe, expect, it, vi } from 'vitest'
import { fetchServeSimAccessibilityTree } from './serve-sim-ax-tree'
function sseResponse(chunks: string[], init: { status?: number } = {}): Response {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
}
})
return new Response(body, {
status: init.status ?? 200,
headers: { 'Content-Type': 'text/event-stream' }
})
}
describe('fetchServeSimAccessibilityTree', () => {
it('returns the first data event and skips the SSE comment preamble', async () => {
const tree = { screen: { width: 393, height: 852 }, elements: [{ label: 'Login' }], errors: [] }
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', `data: ${JSON.stringify(tree)}\n\n`]))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual(tree)
})
it('prefers a live update over the replayed cached tree', async () => {
// The helper replays the cached tree to new clients, then polls the device
// and writes a fresh event only if the tree changed — the fresh one must win.
const stale = JSON.stringify({ elements: [{ label: 'Old' }] })
const fresh = JSON.stringify({ elements: [{ label: 'New' }] })
const fetchImpl = vi.fn(async () =>
sseResponse([':\n\n', `data: ${stale}\n\n`, `data: ${fresh}\n\n`])
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual({ elements: [{ label: 'New' }] })
})
it('settles on the first event when no follow-up arrives within the window', async () => {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(':\n\n'))
controller.enqueue(encoder.encode('data: {"elements":[]}\n\n'))
// Never closes — an unchanged tree writes nothing, so the settle window must end the read.
}
})
const fetchImpl = vi.fn(async () => new Response(body, { status: 200 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, settleMs: 30 })
).resolves.toEqual({ elements: [] })
})
it('handles a data event split across stream chunks', async () => {
const payload = JSON.stringify({ elements: [] })
const mid = Math.floor(payload.length / 2)
const fetchImpl = vi.fn(async () =>
sseResponse([':\n\n', `data: ${payload.slice(0, mid)}`, `${payload.slice(mid)}\n\n`])
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).resolves.toEqual({ elements: [] })
})
it('maps a non-200 response to an actionable stale-helper error', async () => {
const fetchImpl = vi.fn(async () => sseResponse([], { status: 404 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({
code: 'emulator_error',
message: expect.stringContaining('Restart the emulator session')
})
})
it('maps a connection failure to emulator_no_active', async () => {
const fetchImpl = vi.fn(async () => {
throw new TypeError('fetch failed')
})
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_no_active' })
})
it('fails when the stream ends without a data event', async () => {
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', ':\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_helper_failed' })
})
it('times out when no data event arrives', async () => {
const fetchImpl = vi.fn(
async () =>
new Response(
new ReadableStream<Uint8Array>({
start() {
// Never emits and never closes; the timeout abort must win.
}
}),
{ status: 200 }
)
)
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, timeoutMs: 50 })
).rejects.toMatchObject({ code: 'emulator_error', message: expect.stringMatching(/Timed out/) })
})
// Pull-based: controller.error() discards still-queued chunks, so the stream
// must hand out each chunk on its own read before erroring on a later pull.
function droppingSseResponse(chunks: string[]): Response {
const encoder = new TextEncoder()
let step = 0
const body = new ReadableStream<Uint8Array>({
pull(controller) {
if (step < chunks.length) {
controller.enqueue(encoder.encode(chunks[step]))
step += 1
} else {
controller.error(new TypeError('terminated'))
}
}
})
return new Response(body, { status: 200 })
}
it('returns the last captured tree when the stream drops uncleanly mid-read', async () => {
const tree = { elements: [{ label: 'Captured' }] }
const fetchImpl = vi.fn(async () =>
droppingSseResponse([':\n\n', `data: ${JSON.stringify(tree)}\n\n`])
)
// settleMs is long so the drop, not the settle window, ends the read.
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl, settleMs: 5000 })
).resolves.toEqual(tree)
})
it('maps an unclean mid-stream drop with no captured tree to emulator_helper_failed', async () => {
const fetchImpl = vi.fn(async () => droppingSseResponse([':\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({ code: 'emulator_helper_failed' })
})
it('returns the captured tree when the timeout aborts mid-settle', async () => {
const encoder = new TextEncoder()
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(':\n\n'))
controller.enqueue(encoder.encode('data: {"elements":[{"label":"Only"}]}\n\n'))
// Never closes; the hard timeout must abort while the settle window is open.
}
})
const fetchImpl = vi.fn(async () => new Response(body, { status: 200 }))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', {
fetchImpl,
settleMs: 5000,
timeoutMs: 40
})
).resolves.toEqual({ elements: [{ label: 'Only' }] })
})
it('rejects an unparseable ax event with emulator_helper_failed', async () => {
const fetchImpl = vi.fn(async () => sseResponse([':\n\n', 'data: not-json\n\n']))
await expect(
fetchServeSimAccessibilityTree('http://127.0.0.1:3100/ax', { fetchImpl })
).rejects.toMatchObject({
code: 'emulator_helper_failed',
message: expect.stringContaining('unparseable')
})
})
})
+167
View File
@@ -0,0 +1,167 @@
import { EmulatorError } from './emulator-errors'
const DEFAULT_TIMEOUT_MS = 15_000
// Why: /ax replays the last *cached* tree to every new client before polling the
// device, and polling pauses while no client is connected — so the first event
// can be stale. A fresh event is only written if the tree changed, so we linger
// briefly after the first event and return the last one seen: a follow-up means
// the cache was stale; silence means the cache still matches the device.
const DEFAULT_SETTLE_MS = 800
export type FetchAccessibilityTree = (axUrl: string) => Promise<unknown>
export async function fetchServeSimAccessibilityTree(
axUrl: string,
options: { timeoutMs?: number; settleMs?: number; fetchImpl?: typeof fetch } = {}
): Promise<unknown> {
const fetchImpl = options.fetchImpl ?? fetch
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS)
try {
let response: Response
try {
response = await fetchImpl(axUrl, {
headers: { Accept: 'text/event-stream' },
signal: controller.signal
})
} catch {
if (controller.signal.aborted) {
throw timeoutError(axUrl)
}
throw new EmulatorError(
'emulator_no_active',
`serve-sim accessibility endpoint is unreachable at ${axUrl}. Start the emulator session first.`
)
}
if (!response.ok || !response.body) {
// Why: a helper spawned by an older serve-sim build may predate /ax.
throw new EmulatorError(
'emulator_error',
`serve-sim /ax endpoint returned HTTP ${response.status}. Restart the emulator session to refresh the helper.`
)
}
const payload = await readSettledSseDataEvent(
response.body,
controller,
options.settleMs ?? DEFAULT_SETTLE_MS
)
if (payload === null) {
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax stream ended without an accessibility tree event.'
)
}
try {
return JSON.parse(payload)
} catch {
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax returned an unparseable accessibility tree event.'
)
}
} finally {
clearTimeout(timeout)
controller.abort()
}
}
function timeoutError(axUrl: string): EmulatorError {
return new EmulatorError(
'emulator_error',
`Timed out waiting for the accessibility tree from ${axUrl}.`
)
}
const SETTLED = Symbol('settled')
// Returns the payload of the last data event seen up to settleMs after the
// first one (see DEFAULT_SETTLE_MS), or null if the stream ends with none.
async function readSettledSseDataEvent(
body: ReadableStream<Uint8Array>,
controller: AbortController,
settleMs: number
): Promise<string | null> {
const reader = body.getReader()
const decoder = new TextDecoder()
let buffer = ''
let latest: string | null = null
let settled: Promise<typeof SETTLED> | null = null
let settleTimer: NodeJS.Timeout | undefined
// Why: abort must also win over a body that never emits — reads on a stalled
// stream do not observe the fetch signal on their own.
const aborted = new Promise<never>((_, reject) => {
const rejectAborted = (): void => reject(new Error('aborted'))
if (controller.signal.aborted) {
rejectAborted()
return
}
controller.signal.addEventListener('abort', rejectAborted, { once: true })
})
aborted.catch(() => {})
try {
for (;;) {
const result = await Promise.race(
settled ? [reader.read(), aborted, settled] : [reader.read(), aborted]
)
if (result === SETTLED) {
return latest
}
const { done, value } = result
if (value) {
buffer += decoder.decode(value, { stream: true })
const payload = extractLastDataPayload(buffer)
if (payload !== null) {
latest = payload
settled ??= new Promise((resolve) => {
settleTimer = setTimeout(() => resolve(SETTLED), settleMs)
})
}
// Keep only the trailing partial event; complete ones are consumed.
const lastBoundary = buffer.lastIndexOf('\n\n')
if (lastBoundary !== -1) {
buffer = buffer.slice(lastBoundary + 2)
}
}
if (done) {
return latest
}
}
} catch {
// A tree was captured before the stream aborted or dropped — return it rather
// than discarding a valid result on an unclean close (helper crash / truncated
// chunked stream), which also keeps a raw non-EmulatorError off the caller path.
if (latest !== null) {
return latest
}
if (controller.signal.aborted) {
throw new EmulatorError(
'emulator_error',
'Timed out waiting for the accessibility tree event from serve-sim.'
)
}
// Map an unclean mid-stream failure to the EmulatorError contract callers expect.
throw new EmulatorError(
'emulator_helper_failed',
'serve-sim /ax stream ended unexpectedly. Restart the emulator session and retry.'
)
} finally {
if (settleTimer) {
clearTimeout(settleTimer)
}
reader.releaseLock()
}
}
function extractLastDataPayload(buffer: string): string | null {
let payload: string | null = null
for (const event of buffer.split('\n\n').slice(0, -1)) {
const dataLines = event
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).replace(/^ /, ''))
if (dataLines.length > 0) {
payload = dataLines.join('\n')
}
}
return payload
}
@@ -1,5 +1,23 @@
import { describe, expect, it } from 'vitest'
import { parseServeSimDetachedSession } from './serve-sim-detached-session'
import { deriveServeSimAxUrl, parseServeSimDetachedSession } from './serve-sim-detached-session'
describe('deriveServeSimAxUrl', () => {
it('swaps the mjpeg suffix for /ax, preserving the path prefix', () => {
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/stream.mjpeg')).toBe(
'http://127.0.0.1:3100/ax'
)
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/device-1/stream.mjpeg')).toBe(
'http://127.0.0.1:3100/device-1/ax'
)
})
it('does not derive from a non-mjpeg, query-tailed, or missing stream url', () => {
// A query string defeats the suffix match, so no /ax is fabricated.
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/stream.mjpeg?token=x')).toBeUndefined()
expect(deriveServeSimAxUrl('http://127.0.0.1:3100/custom-stream')).toBeUndefined()
expect(deriveServeSimAxUrl(undefined)).toBeUndefined()
})
})
describe('parseServeSimDetachedSession', () => {
it('uses serve-sim streamUrl when present', () => {
@@ -31,4 +49,44 @@ describe('parseServeSimDetachedSession', () => {
expect(info.streamUrl).toBe('http://127.0.0.1:3100/stream.mjpeg')
})
it('derives the ax endpoint when serve-sim omits axUrl', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/ws'
},
'device-1'
)
expect(info.axUrl).toBe('http://127.0.0.1:3100/ax')
})
it('does not fabricate an ax endpoint from a non-mjpeg stream url', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/custom-stream',
wsUrl: 'ws://127.0.0.1:3100/ws'
},
'device-1'
)
expect(info.axUrl).toBeUndefined()
})
it('keeps an explicit axUrl when serve-sim provides one', () => {
const info = parseServeSimDetachedSession(
{
device: 'device-1',
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/ws',
axUrl: 'http://127.0.0.1:3100/custom-ax'
},
'device-1'
)
expect(info.axUrl).toBe('http://127.0.0.1:3100/custom-ax')
})
})
@@ -8,6 +8,15 @@ function streamUrlFromServeSimUrl(url: string): string {
return url.endsWith('/stream.mjpeg') ? url : `${url.replace(/\/$/, '')}/stream.mjpeg`
}
// Why: serve-sim serves /ax on the helper but omits it from --detach output.
// Guarded on the mjpeg suffix so a foreign stream URL never masquerades as an
// AX endpoint. Also used by the bridge to heal sessions registered without axUrl.
export function deriveServeSimAxUrl(streamUrl: string | undefined): string | undefined {
return streamUrl?.endsWith('/stream.mjpeg')
? streamUrl.replace(/\/stream\.mjpeg$/, '/ax')
: undefined
}
export function parseServeSimDetachedSession(raw: unknown, udid: string): EmulatorSessionInfo {
if (!raw || typeof raw !== 'object') {
throw new EmulatorError('emulator_helper_failed', 'serve-sim did not return stream endpoints.')
@@ -24,7 +33,7 @@ export function parseServeSimDetachedSession(raw: unknown, udid: string): Emulat
deviceUdid: typeof json.device === 'string' ? json.device : udid,
wsUrl: wsUrl ?? '',
streamUrl: streamUrl ?? '',
axUrl: typeof json.axUrl === 'string' ? json.axUrl : undefined
axUrl: typeof json.axUrl === 'string' ? json.axUrl : deriveServeSimAxUrl(streamUrl)
}
if (!info.streamUrl || !info.wsUrl) {
throw new EmulatorError('emulator_helper_failed', 'serve-sim did not return stream endpoints.')
+5 -5
View File
@@ -249,11 +249,11 @@ export class RuntimeEmulatorCommands {
async emulatorAx(params: EmulatorTargetParams): Promise<unknown> {
const worktreeId = await this.resolveWorktreeId(params.worktree)
return this.requireEmulatorBridge().runCapability(
'accessibilityTree',
{ device: params.device ?? params.emulator, worktreeId },
(backend, device) => backend.accessibilityTree!(device)
)
// Via the bridge (not runCapability directly) so iOS gets the session's /ax endpoint.
return this.requireEmulatorBridge().accessibilityTree({
device: params.device ?? params.emulator,
worktreeId
})
}
async emulatorLogcat(