fix(emulator): iOS ax via plain-JSON serve-sim helper (supersedes #10007) (#10029)

* Revert "Enable accessibility tree (`ax`) command on iOS emulator sessions (#10007)"

This reverts commit 43ae014a64.

* fix(emulator): expose iOS accessibility tree

* fix(emulator): support device-only iOS AX

* fix(emulator): normalize iOS ax to 0..1 and heal missing axUrl

serve-sim's helper /ax reports element frames in absolute pixels, but
tap/gesture take normalized 0..1 coords. Normalize the raw AX node tree
into a compact nested shape whose frames are 0..1 over the device screen
(first root's frame), mirroring serve-sim's own normalizeAxTree, so agents
can feed ax output straight back into input commands.

Also heal sessions that were registered without an axUrl: #9924 only
derived /ax at parse time, so already-active sessions had no endpoint.
The bridge now derives it from the session's mjpeg stream URL, guarded to
the /stream.mjpeg suffix so a non-mjpeg URL never fabricates a bogus /ax.

* docs(emulator): mark ax working on iOS with correct raw-AX-tree shape

Both skill guides and the CLI summary described iOS ax as unsupported (or,
via the reverted #10007, as a normalized "screen + elements" shape that
never matched the endpoint). ax works on both backends: Android via
uiautomator, iOS via the serve-sim helper. Document the real iOS output —
a raw AX node tree (labels, roles, nested children) with frames normalized
to 0..1 — and regenerate the bundled skill guides.

* chore(skills): regenerate skill bundle manifests

CI verify failed because generated skill artifacts were stale after version/skill revision bumps.

* fix(emulator): read ax from explicit device without active session

Fall back to udid-keyed session lookup when a worktree has no active emulator,
allowing `--device` targeting to work the same way for ax as it does for tap/type.
Also clarify in docs that AX frames are normalized 0..1 with top-left origin,
and show how to tap an element at its frame center (x+width/2, y+height/2).

* fix(emulator): cap iOS AX tree at 500 nodes

Unbounded accessibility trees can flood agent output. Enforce a 500-node limit (matching serve-sim's snapshot cap) and mark truncated parents so consumers know the tree was cut.

---------

Co-authored-by: 5Hyeons <ohs2251@naver.com>
This commit is contained in:
Jinjing 2026-07-22 21:30:53 -07:00 committed by GitHub
parent ee6319ebe4
commit 4a9affd6e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 737 additions and 536 deletions

View File

@ -118,8 +118,9 @@ Use `--json` for agent-friendly output. Coordinates are **normalized 0..1**
- `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/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).
fail against an iOS device with `emulator_unsupported`. `ax` works on **both**,
with backend-specific output (Android: `uiautomator` node tree; iOS: serve-sim
raw AX node tree with frames normalized to 0..1).
- No camera/sensor injection yet.
## Targeting devices & worktrees

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>]` | Raw serve-sim AX snapshot (screen + elements). Needs an active session. |
| Accessibility tree | `ORCA emulator ax [--device <id>]` | Raw serve-sim AX node tree (labels, roles, nested children, capped at 500 nodes; frames normalized 0..1 with top-left origin — tap an element at its frame center: x+width/2, y+height/2). 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

View File

@ -108,7 +108,7 @@ export const EMULATOR_COMMAND_SPECS: CommandSpec[] = [
},
{
path: ['emulator', 'ax'],
summary: 'Dump the device accessibility tree (uiautomator on Android, serve-sim AX on iOS)',
summary: 'Dump the accessibility tree (Android uiautomator; iOS serve-sim AX, frames 0..1)',
usage: 'orca emulator ax [--device <id>] [--worktree <selector>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'device', 'emulator', 'worktree']
},

View File

@ -3,7 +3,6 @@ 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) => {
@ -220,26 +219,6 @@ 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(() => {

View File

@ -80,8 +80,8 @@ export type EmulatorBackend = {
rotate(deviceId: string, orientation: string): Promise<void>
exec(deviceId: string, command: string): Promise<unknown>
// Capability-gated verbs. 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,9 +90,7 @@ export type EmulatorBackend = {
packageName: string,
permission?: string
): Promise<void>
// 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>
accessibilityTree?(deviceId: string, axUrl?: string): Promise<unknown>
logcat?(
deviceId: string,
options?: { lines?: number; filters?: readonly string[] }

View File

@ -11,7 +11,8 @@ const {
listServeSimHelperProcessesForDeviceMock,
shutdownSimulatorDeviceMock,
sendEmulatorGestureSequenceMock,
parseServeSimDetachedSessionMock
parseServeSimDetachedSessionMock,
netFetchMock
} = vi.hoisted(() => ({
ensureSimulatorBootedMock: vi.fn(async () => {}),
execServeSimCommandMock: vi.fn(async (_executable?: unknown, _args?: string[]) => ({})),
@ -21,9 +22,12 @@ const {
listServeSimHelperProcessesForDeviceMock: vi.fn(async (): Promise<ServeSimHelperProcess[]> => []),
shutdownSimulatorDeviceMock: vi.fn(async () => {}),
sendEmulatorGestureSequenceMock: vi.fn(async () => {}),
parseServeSimDetachedSessionMock: vi.fn()
parseServeSimDetachedSessionMock: vi.fn(),
netFetchMock: vi.fn()
}))
vi.mock('electron', () => ({ net: { fetch: netFetchMock } }))
vi.mock('../serve-sim-execution', () => ({
execServeSimCommand: execServeSimCommandMock,
parseServeSimCommandArgs: vi.fn((input: string) => input.split(' ').filter(Boolean)),
@ -81,9 +85,10 @@ describe('IosEmulatorBackend', () => {
sendEmulatorGestureSequenceMock.mockReset()
sendEmulatorGestureSequenceMock.mockImplementation(async () => {})
parseServeSimDetachedSessionMock.mockReset()
netFetchMock.mockReset()
})
it('declares ios kind, mjpeg codec, and only the ax explicit-verb capability', () => {
it('advertises the iOS accessibility tree capability', () => {
const backend = new IosEmulatorBackend()
expect(backend.kind).toBe('ios')
expect(backend.streamCodec).toBe('mjpeg')
@ -96,16 +101,69 @@ describe('IosEmulatorBackend', () => {
})
})
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 })
it('fetches and normalizes the serve-sim accessibility tree', async () => {
const raw = [
{
type: 'Application',
role_description: 'application',
AXLabel: 'Demo',
enabled: true,
frame: { x: 0, y: 0, width: 400, height: 800 },
children: [
{
type: 'Button',
role_description: 'button',
AXLabel: 'Continue',
AXValue: '',
enabled: true,
frame: { x: 100, y: 400, width: 200, height: 50 },
children: []
}
]
}
]
netFetchMock.mockResolvedValue(new Response(JSON.stringify(raw), { status: 200 }))
const backend = new IosEmulatorBackend()
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({
).resolves.toEqual([
{
role: 'application',
type: 'Application',
label: 'Demo',
value: '',
enabled: true,
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: 'button',
type: 'Button',
label: 'Continue',
value: '',
enabled: true,
frame: { x: 0.25, y: 0.5, width: 0.5, height: 0.0625 },
children: []
}
]
}
])
expect(netFetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3100/ax',
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('reports missing sessions and temporarily unavailable AX endpoints', async () => {
const backend = new IosEmulatorBackend()
await expect(backend.accessibilityTree('device-1')).rejects.toMatchObject({
code: 'emulator_no_active'
})
netFetchMock.mockResolvedValue(new Response('{"error":"ax_unavailable"}', { status: 503 }))
await expect(
backend.accessibilityTree('device-1', 'http://127.0.0.1:3100/ax')
).rejects.toMatchObject({ code: 'emulator_helper_failed' })
})
it('taps via serve-sim with the resolved device', async () => {

View File

@ -23,7 +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 { requestServeSimAccessibilityTree } from '../serve-sim-accessibility-tree'
import { hideNativeSimulatorApp } from '../simulator-app-visibility'
import type {
BackendAvailability,
@ -38,8 +38,6 @@ import type {
export class IosEmulatorBackend implements EmulatorBackend {
readonly kind = 'ios' as const
readonly streamCodec = 'mjpeg' as const
// 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,
@ -50,11 +48,9 @@ export class IosEmulatorBackend implements EmulatorBackend {
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
@ -174,24 +170,22 @@ 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()))
return this.execServeSim([...rawArgs, '-d', udid], { json: true })
}
async accessibilityTree(_deviceId: string, axUrl?: string): Promise<unknown> {
if (!axUrl) {
throw new EmulatorError(
'emulator_no_active',
'No active iOS emulator AX endpoint — attach the simulator first.'
)
}
return requestServeSimAccessibilityTree(axUrl)
}
async startSession(deviceId: string): Promise<EmulatorSessionInfo> {
const udid = await this.resolveDeviceId(deviceId)
await ensureSimulatorBooted(udid)

View File

@ -14,5 +14,4 @@ export type EmulatorSessionState = {
export type EmulatorBridgeOptions = {
waitForEndpointReady?: (endpoint: string) => Promise<boolean>
fetchAccessibilityTree?: (axUrl: string) => Promise<unknown>
}

View File

@ -9,16 +9,20 @@ const {
killServeSimHelperProcessesForDeviceMock,
listSimulatorDevicesMock,
listServeSimHelperProcessesForDeviceMock,
shutdownSimulatorDeviceMock
shutdownSimulatorDeviceMock,
netFetchMock
} = vi.hoisted(() => ({
execServeSimCommandMock: vi.fn(async () => ({})),
hideNativeSimulatorAppMock: vi.fn(async () => {}),
killServeSimHelperProcessesForDeviceMock: vi.fn(async () => {}),
listSimulatorDevicesMock: vi.fn(async (): Promise<SimulatorDevice[]> => []),
listServeSimHelperProcessesForDeviceMock: vi.fn(async (): Promise<ServeSimHelperProcess[]> => []),
shutdownSimulatorDeviceMock: vi.fn(async () => {})
shutdownSimulatorDeviceMock: vi.fn(async () => {}),
netFetchMock: vi.fn()
}))
vi.mock('electron', () => ({ net: { fetch: netFetchMock } }))
vi.mock('./serve-sim-execution', () => ({
execServeSimCommand: execServeSimCommandMock,
parseServeSimCommandArgs: vi.fn(() => []),
@ -62,6 +66,7 @@ function session(deviceUdid: string): EmulatorSessionInfo {
deviceUdid,
streamUrl: `http://127.0.0.1:3100/${deviceUdid}`,
wsUrl: `ws://127.0.0.1:3100/${deviceUdid}`,
axUrl: `http://127.0.0.1:3100/${deviceUdid}/ax`,
helperPid: 1234,
// iOS serve-sim sessions round-trip through the registry as mjpeg.
streamCodec: 'mjpeg'
@ -84,6 +89,7 @@ describe('EmulatorBridge helper ownership', () => {
hideNativeSimulatorAppMock.mockImplementation(async () => {})
shutdownSimulatorDeviceMock.mockReset()
shutdownSimulatorDeviceMock.mockImplementation(async () => {})
netFetchMock.mockReset()
})
it('stops the previous Orca-managed helper when a worktree switches devices', async () => {
@ -184,51 +190,12 @@ 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 does not advertise install.
// device-1 resolves to the iOS backend, which advertises no explicit-verb caps.
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 })
@ -391,6 +358,190 @@ describe('RuntimeEmulatorCommands attach lifecycle', () => {
hideNativeSimulatorAppMock.mockImplementation(async () => {})
shutdownSimulatorDeviceMock.mockReset()
shutdownSimulatorDeviceMock.mockImplementation(async () => {})
netFetchMock.mockReset()
})
it('reads iOS accessibility from the active worktree session', async () => {
const tree = [{ type: 'Application', children: [] }]
netFetchMock.mockResolvedValue(new Response(JSON.stringify(tree), { status: 200 }))
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
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
})
})
// Routing test: normalization is covered in serve-sim-ax-normalization.test.ts.
await expect(commands.emulatorAx({ worktree: 'wt-1' })).resolves.toMatchObject([
{ type: 'Application' }
])
expect(netFetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3100/device-1/ax',
expect.any(Object)
)
})
it('reads iOS accessibility from an attached device without a worktree', async () => {
const tree = [{ type: 'Application', children: [] }]
netFetchMock.mockResolvedValue(new Response(JSON.stringify(tree), { status: 200 }))
listSimulatorDevicesMock.mockResolvedValue([
{
name: 'iPhone attached',
udid: 'device-1',
state: 'Booted',
runtime: 'iOS 26.0'
}
])
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator('wt-1', session('device-1'), { managed: true })
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({ device: 'device-1' })).resolves.toMatchObject([
{ type: 'Application' }
])
expect(netFetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3100/device-1/ax',
expect.any(Object)
)
})
it('reads ax for an explicit device when the worktree has no active session', async () => {
const tree = [{ type: 'Application', children: [] }]
netFetchMock.mockResolvedValue(new Response(JSON.stringify(tree), { status: 200 }))
listSimulatorDevicesMock.mockResolvedValue([
{
name: 'iPhone elsewhere',
udid: 'device-1',
state: 'Booted',
runtime: 'iOS 26.0'
}
])
const bridge = new EmulatorBridge()
// The session lives under another worktree; the CLI still resolves the
// caller's cwd worktree, which has nothing attached.
bridge.registerActiveEmulator('wt-other', session('device-1'), { managed: true })
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({ device: 'device-1', worktree: 'wt-1' })
).resolves.toMatchObject([{ type: 'Application' }])
expect(netFetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3100/device-1/ax',
expect.any(Object)
)
})
it('reports when the requested iOS device differs from the active session', async () => {
listSimulatorDevicesMock.mockResolvedValue([
{
name: 'iPhone requested',
udid: 'device-requested',
state: 'Booted',
runtime: 'iOS 26.0'
}
])
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator('wt-1', session('device-active'), { managed: true })
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({ device: 'device-requested', worktree: 'wt-1' })
).rejects.toMatchObject({
code: 'emulator_no_active',
message: expect.stringContaining('active: device-active')
})
expect(netFetchMock).not.toHaveBeenCalled()
})
it('heals a session registered without an axUrl by deriving it from the stream url', async () => {
const tree = [{ type: 'Application', children: [] }]
netFetchMock.mockResolvedValue(new Response(JSON.stringify(tree), { status: 200 }))
const bridge = new EmulatorBridge()
// No axUrl on the registered session (e.g. reattach path predating derivation).
bridge.registerActiveEmulator(
'wt-1',
{
deviceUdid: 'device-1',
streamUrl: 'http://127.0.0.1:3100/helper/device-1/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/helper/device-1/ws',
streamCodec: 'mjpeg'
},
{ managed: true }
)
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.toMatchObject([
{ type: 'Application' }
])
expect(netFetchMock).toHaveBeenCalledWith(
'http://127.0.0.1:3100/helper/device-1/ax',
expect.any(Object)
)
})
it('does not fabricate an /ax endpoint from a non-mjpeg stream url', async () => {
const bridge = new EmulatorBridge()
bridge.registerActiveEmulator(
'wt-1',
{
deviceUdid: 'device-1',
streamUrl: 'http://127.0.0.1:3100/helper/device-1/stream.h264',
wsUrl: 'ws://127.0.0.1:3100/helper/device-1/ws',
streamCodec: 'mjpeg'
},
{ managed: true }
)
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' })).rejects.toMatchObject({
code: 'emulator_no_active'
})
expect(netFetchMock).not.toHaveBeenCalled()
})
it('reconnects to an existing active helper instead of replacing it', async () => {
@ -420,29 +571,6 @@ 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({

View File

@ -5,7 +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 { deriveAxUrlFromStreamUrl } from './serve-sim-detached-session'
import { IosEmulatorBackend } from './backends/ios-emulator-backend'
import { AndroidEmulatorBackend } from './backends/android-emulator-backend'
import type {
@ -202,11 +202,26 @@ export class EmulatorBridge {
async accessibilityTree(opts?: EmulatorTargetOpts): Promise<unknown> {
return this.runCapability('accessibilityTree', opts, async (backend, device) => {
if (backend.kind !== 'ios') {
return backend.accessibilityTree!(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
const worktreeId = opts?.worktreeId
// Fall back to the udid-keyed session so an explicit --device read works
// from a worktree with no active emulator (matching tap/type reachability);
// sessions are stored once per udid, so both lookups hit the same state.
const session =
(worktreeId ? this.getActiveForWorktree(worktreeId) : null) ??
this.sessionRegistry.getSession(udid)
if (worktreeId && session && session.deviceUdid !== udid) {
throw new EmulatorError(
'emulator_no_active',
`iOS simulator ${udid} is not active for this worktree (active: ${session.deviceUdid}); attach the requested simulator first.`
)
}
// Heal sessions registered without an axUrl (parse-time derivation only
// covers fresh --detach output) by deriving it from the mjpeg stream URL.
const axUrl = session?.axUrl ?? deriveAxUrlFromStreamUrl(session?.streamUrl)
return backend.accessibilityTree!(udid, axUrl)
})
}

View File

@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { netFetchMock } = vi.hoisted(() => ({ netFetchMock: vi.fn() }))
vi.mock('electron', () => ({ net: { fetch: netFetchMock } }))
import { requestServeSimAccessibilityTree } from './serve-sim-accessibility-tree'
const AX_URL = 'http://127.0.0.1:3100/ax'
describe('requestServeSimAccessibilityTree', () => {
beforeEach(() => {
netFetchMock.mockReset()
})
it('fetches the one-shot JSON tree and returns it normalized to 0..1', async () => {
const raw = [
{
type: 'Application',
role_description: 'application',
AXLabel: 'Root',
enabled: true,
frame: { x: 0, y: 0, width: 200, height: 400 },
children: [
{
type: 'Button',
role_description: 'button',
AXLabel: 'OK',
enabled: true,
frame: { x: 50, y: 100, width: 100, height: 40 },
children: []
}
]
}
]
netFetchMock.mockResolvedValue(new Response(JSON.stringify(raw), { status: 200 }))
const tree = await requestServeSimAccessibilityTree(AX_URL)
expect(tree).toEqual([
{
role: 'application',
type: 'Application',
label: 'Root',
value: '',
enabled: true,
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: 'button',
type: 'Button',
label: 'OK',
value: '',
enabled: true,
frame: { x: 0.25, y: 0.25, width: 0.5, height: 0.1 },
children: []
}
]
}
])
expect(netFetchMock).toHaveBeenCalledWith(
AX_URL,
expect.objectContaining({ signal: expect.any(AbortSignal) })
)
})
it('surfaces a retry hint when accessibility is temporarily unavailable (503)', async () => {
netFetchMock.mockResolvedValue(new Response('{"error":"ax_unavailable"}', { status: 503 }))
await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({
code: 'emulator_helper_failed',
message: expect.stringContaining('retry')
})
})
it('rejects a non-array or unparseable payload', async () => {
netFetchMock.mockResolvedValueOnce(new Response('{"not":"an array"}', { status: 200 }))
await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({
code: 'emulator_error'
})
netFetchMock.mockResolvedValueOnce(new Response('not json', { status: 200 }))
await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({
code: 'emulator_error'
})
})
it('maps a network failure to a helper error', async () => {
netFetchMock.mockRejectedValue(new Error('connect ECONNREFUSED'))
await expect(requestServeSimAccessibilityTree(AX_URL)).rejects.toMatchObject({
code: 'emulator_helper_failed',
message: expect.stringContaining('Unable to read serve-sim AX')
})
})
})

View File

@ -0,0 +1,50 @@
import { net } from 'electron'
import { EmulatorError } from './emulator-errors'
import { normalizeServeSimAxTree, type NormalizedAxNode } from './serve-sim-ax-normalization'
const AX_REQUEST_TIMEOUT_MS = 5_000
const MAX_ERROR_BODY_LENGTH = 512
export async function requestServeSimAccessibilityTree(axUrl: string): Promise<NormalizedAxNode[]> {
try {
const response = await net.fetch(axUrl, {
signal: AbortSignal.timeout(AX_REQUEST_TIMEOUT_MS)
})
const body = await response.text()
if (!response.ok) {
const detail = body.slice(0, MAX_ERROR_BODY_LENGTH) || response.statusText
const retry = response.status === 503 ? ' Accessibility may still be warming up; retry.' : ''
throw new EmulatorError(
'emulator_helper_failed',
`serve-sim AX request failed (${response.status}): ${detail}.${retry}`
)
}
let tree: unknown
try {
tree = JSON.parse(body)
} catch {
throw new EmulatorError('emulator_error', 'serve-sim AX returned invalid JSON.')
}
if (
!Array.isArray(tree) ||
tree.some((node) => typeof node !== 'object' || node === null || Array.isArray(node))
) {
throw new EmulatorError('emulator_error', 'serve-sim AX returned an invalid tree.')
}
// serve-sim reports frames in absolute pixels; normalize to 0..1 so the
// output feeds straight back into tap/gesture.
return normalizeServeSimAxTree(tree)
} catch (error) {
if (error instanceof EmulatorError) {
throw error
}
const detail =
error instanceof Error && error.name === 'TimeoutError'
? 'request timed out'
: error instanceof Error
? error.message
: 'unknown request failure'
throw new EmulatorError('emulator_helper_failed', `Unable to read serve-sim AX: ${detail}`)
}
}

View File

@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { normalizeServeSimAxTree } from './serve-sim-ax-normalization'
describe('normalizeServeSimAxTree', () => {
it('normalizes frames to 0..1 over the first root screen frame and nests children', () => {
const raw = [
{
type: 'Application',
role_description: 'application',
AXLabel: 'Demo',
AXValue: '',
AXUniqueId: null,
enabled: true,
frame: { x: 0, y: 0, width: 400, height: 800 },
children: [
{
type: 'Button',
role_description: 'button',
AXLabel: 'Continue',
AXValue: 'go',
AXUniqueId: 'btn-1',
enabled: true,
frame: { x: 100, y: 400, width: 200, height: 50 },
children: []
}
]
}
]
expect(normalizeServeSimAxTree(raw)).toEqual([
{
role: 'application',
type: 'Application',
label: 'Demo',
value: '',
enabled: true,
frame: { x: 0, y: 0, width: 1, height: 1 },
children: [
{
role: 'button',
type: 'Button',
label: 'Continue',
value: 'go',
enabled: true,
id: 'btn-1',
frame: { x: 0.25, y: 0.5, width: 0.5, height: 0.0625 },
children: []
}
]
}
])
})
it('normalizes relative to a screen frame with a non-zero origin', () => {
const raw = [
{
type: 'Window',
frame: { x: 10, y: 20, width: 200, height: 400 },
children: [
{ type: 'Cell', frame: { x: 60, y: 120, width: 100, height: 100 }, children: [] }
]
}
]
const [root] = normalizeServeSimAxTree(raw)
expect(root.frame).toEqual({ x: 0, y: 0, width: 1, height: 1 })
expect(root.children[0]!.frame).toEqual({ x: 0.25, y: 0.25, width: 0.5, height: 0.25 })
})
it('marks a disabled element and defaults missing text fields to empty strings', () => {
const raw = [
{
type: 'StaticText',
enabled: false,
frame: { x: 0, y: 0, width: 100, height: 100 },
children: []
}
]
expect(normalizeServeSimAxTree(raw)[0]).toMatchObject({
role: '',
type: 'StaticText',
label: '',
value: '',
enabled: false
})
})
it('caps the tree at 500 nodes and marks the parent whose children were cut', () => {
const child = (label: string) => ({
type: 'StaticText',
AXLabel: label,
frame: { x: 0, y: 0, width: 10, height: 10 },
children: []
})
const raw = [
{
type: 'Application',
frame: { x: 0, y: 0, width: 400, height: 800 },
children: Array.from({ length: 600 }, (_, i) => child(`row-${i}`))
}
]
const [root] = normalizeServeSimAxTree(raw)
// Root consumes one slot of the 500-node budget.
expect(root.children).toHaveLength(499)
expect(root.truncated).toBe(true)
expect(root.children[0]!.truncated).toBeUndefined()
})
it('falls back to a unit screen for malformed roots instead of dividing by zero', () => {
const raw = [{ type: 'Application', children: [] }]
expect(normalizeServeSimAxTree(raw)).toEqual([
{
role: '',
type: 'Application',
label: '',
value: '',
enabled: true,
frame: { x: 0, y: 0, width: 0, height: 0 },
children: []
}
])
expect(normalizeServeSimAxTree([])).toEqual([])
})
})

View File

@ -0,0 +1,114 @@
// Normalizes serve-sim's raw /ax node tree into a compact nested tree whose
// frames are in 0..1 device coordinates. serve-sim's helper reports frames in
// absolute pixels; `tap`/`gesture` take normalized 0..1 — so we normalize here
// to let agents feed element positions straight back into input commands.
// Frame derivation mirrors normalizeAxTree in serve-sim/src/ax.ts: the first
// root's frame is the device screen.
export type NormalizedAxFrame = { x: number; y: number; width: number; height: number }
// Matches serve-sim's own snapshot cap; an unbounded tree can flood agent output.
const MAX_AX_NODES = 500
// One accessibility element, position normalized, children nested (raw tree shape).
export type NormalizedAxNode = {
role: string
type: string
label: string
value: string
enabled: boolean
id?: string
frame: NormalizedAxFrame
children: NormalizedAxNode[]
// Present when children were dropped by the node cap.
truncated?: true
}
function asRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
function numeric(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function asString(value: unknown): string {
return typeof value === 'string' ? value : ''
}
function readFrame(value: unknown): NormalizedAxFrame {
const frame = asRecord(value)
return {
x: numeric(frame.x),
y: numeric(frame.y),
width: numeric(frame.width),
height: numeric(frame.height)
}
}
// Fall back to a unit screen so a malformed/empty root never divides by zero.
function screenFrame(roots: unknown[]): NormalizedAxFrame {
const first = readFrame(asRecord(roots[0]).frame)
return first.width > 0 && first.height > 0 ? first : { x: 0, y: 0, width: 1, height: 1 }
}
function round4(value: number): number {
return Math.round(value * 10_000) / 10_000
}
function normalizeFrame(frame: NormalizedAxFrame, screen: NormalizedAxFrame): NormalizedAxFrame {
return {
x: round4((frame.x - screen.x) / screen.width),
y: round4((frame.y - screen.y) / screen.height),
width: round4(frame.width / screen.width),
height: round4(frame.height / screen.height)
}
}
function normalizeNode(
raw: unknown,
screen: NormalizedAxFrame,
budget: { remaining: number }
): NormalizedAxNode {
budget.remaining -= 1
const node = asRecord(raw)
const rawChildren = Array.isArray(node.children) ? node.children : []
const children: NormalizedAxNode[] = []
for (const child of rawChildren) {
if (budget.remaining <= 0) {
break
}
children.push(normalizeNode(child, screen, budget))
}
const normalized: NormalizedAxNode = {
role: asString(node.role_description),
type: asString(node.type),
label: asString(node.AXLabel),
value: asString(node.AXValue),
enabled: node.enabled !== false,
frame: normalizeFrame(readFrame(node.frame), screen),
children
}
// AXUniqueId is often null; only surface it when the helper provides one.
const uniqueId = asString(node.AXUniqueId)
if (uniqueId) {
normalized.id = uniqueId
}
if (children.length < rawChildren.length) {
normalized.truncated = true
}
return normalized
}
export function normalizeServeSimAxTree(roots: unknown[]): NormalizedAxNode[] {
const screen = screenFrame(roots)
const budget = { remaining: MAX_AX_NODES }
const normalized: NormalizedAxNode[] = []
for (const root of roots) {
if (budget.remaining <= 0) {
break
}
normalized.push(normalizeNode(root, screen, budget))
}
return normalized
}

View File

@ -1,175 +0,0 @@
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')
})
})
})

View File

@ -1,167 +0,0 @@
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
}

View File

@ -1,23 +1,8 @@
import { describe, expect, it } from 'vitest'
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()
})
})
import {
deriveAxUrlFromStreamUrl,
parseServeSimDetachedSession
} from './serve-sim-detached-session'
describe('parseServeSimDetachedSession', () => {
it('uses serve-sim streamUrl when present', () => {
@ -33,10 +18,32 @@ describe('parseServeSimDetachedSession', () => {
expect(info).toMatchObject({
deviceUdid: 'device-1',
streamUrl: 'http://127.0.0.1:3100/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3100/ws'
wsUrl: 'ws://127.0.0.1:3100/ws',
axUrl: 'http://127.0.0.1:3100/ax'
})
})
it('derives the device-scoped AX endpoint and preserves an explicit one', () => {
const derived = parseServeSimDetachedSession(
{
streamUrl: 'http://127.0.0.1:3200/helper/device-1/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3200/helper/device-1/ws'
},
'device-1'
)
const explicit = parseServeSimDetachedSession(
{
streamUrl: 'http://127.0.0.1:3200/stream.mjpeg',
wsUrl: 'ws://127.0.0.1:3200/ws',
axUrl: 'http://127.0.0.1:3200/custom-ax'
},
'device-1'
)
expect(derived.axUrl).toBe('http://127.0.0.1:3200/helper/device-1/ax')
expect(explicit.axUrl).toBe('http://127.0.0.1:3200/custom-ax')
})
it('derives the MJPEG stream endpoint from older serve-sim url output', () => {
const info = parseServeSimDetachedSession(
{
@ -49,44 +56,21 @@ 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'
describe('deriveAxUrlFromStreamUrl', () => {
it('swaps the mjpeg stream suffix for /ax', () => {
expect(deriveAxUrlFromStreamUrl('http://127.0.0.1:3100/stream.mjpeg')).toBe(
'http://127.0.0.1:3100/ax'
)
expect(deriveAxUrlFromStreamUrl('http://127.0.0.1:3200/helper/device-1/stream.mjpeg')).toBe(
'http://127.0.0.1:3200/helper/device-1/ax'
)
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')
it('never fabricates an /ax endpoint from a non-mjpeg or missing url', () => {
expect(deriveAxUrlFromStreamUrl('http://127.0.0.1:3100/stream.h264')).toBeUndefined()
expect(deriveAxUrlFromStreamUrl('http://127.0.0.1:3100/')).toBeUndefined()
expect(deriveAxUrlFromStreamUrl(undefined)).toBeUndefined()
})
})

View File

@ -4,17 +4,19 @@ import { tmpdir } from 'node:os'
import { EmulatorError } from './emulator-errors'
import type { EmulatorSessionInfo } from './emulator-types'
const MJPEG_STREAM_SUFFIX = '/stream.mjpeg'
function streamUrlFromServeSimUrl(url: string): string {
return url.endsWith('/stream.mjpeg') ? url : `${url.replace(/\/$/, '')}/stream.mjpeg`
return url.endsWith(MJPEG_STREAM_SUFFIX) ? url : `${url.replace(/\/$/, '')}${MJPEG_STREAM_SUFFIX}`
}
// 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
// Derive the helper /ax endpoint by swapping the mjpeg stream suffix. Guarded to
// that suffix so a non-mjpeg stream URL never fabricates a bogus /ax endpoint.
export function deriveAxUrlFromStreamUrl(streamUrl: string | undefined): string | undefined {
if (!streamUrl || !streamUrl.endsWith(MJPEG_STREAM_SUFFIX)) {
return undefined
}
return `${streamUrl.slice(0, -MJPEG_STREAM_SUFFIX.length)}/ax`
}
export function parseServeSimDetachedSession(raw: unknown, udid: string): EmulatorSessionInfo {
@ -33,7 +35,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 : deriveServeSimAxUrl(streamUrl)
axUrl: typeof json.axUrl === 'string' ? json.axUrl : deriveAxUrlFromStreamUrl(streamUrl)
}
if (!info.streamUrl || !info.wsUrl) {
throw new EmulatorError('emulator_helper_failed', 'serve-sim did not return stream endpoints.')

View File

@ -249,7 +249,6 @@ export class RuntimeEmulatorCommands {
async emulatorAx(params: EmulatorTargetParams): Promise<unknown> {
const worktreeId = await this.resolveWorktreeId(params.worktree)
// Via the bridge (not runCapability directly) so iOS gets the session's /ax endpoint.
return this.requireEmulatorBridge().accessibilityTree({
device: params.device ?? params.emulator,
worktreeId