From 513516936aa878d2570c8f77055cea6036e8feb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 17:56:07 +0200 Subject: [PATCH 1/5] fix(android): open the macOS emulator window behind the focused app The emulator activates itself when its window opens, and it has no flag to stop that. A GUI launch on macOS now starts a short JXA watcher. When the emulator becomes frontmost, the watcher reactivates the app that was frontmost before it, so the window lands directly below that app. Co-Authored-By: Claude --- ...form-runtime-android-emulator-host.test.ts | 43 +++++++++++++++++++ src/platform-runtime-android-emulator-host.ts | 41 +++++++++++++++++- website/docs/docs/commands.md | 2 +- 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 src/platform-runtime-android-emulator-host.test.ts diff --git a/src/platform-runtime-android-emulator-host.test.ts b/src/platform-runtime-android-emulator-host.test.ts new file mode 100644 index 0000000000..087fda2e7f --- /dev/null +++ b/src/platform-runtime-android-emulator-host.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +const { runCmdDetached } = vi.hoisted(() => ({ runCmdDetached: vi.fn(() => 4242) })); +vi.mock('@agent-device/host-kit/command', () => ({ runCmdDetached })); + +import { createAndroidEmulatorHost } from './platform-runtime-android-emulator-host.ts'; + +beforeEach(() => { + runCmdDetached.mockClear(); +}); + +test('a windowed macOS launch hands focus back from the emulator process', () => { + expect(createAndroidEmulatorHost('darwin').launch('Pixel_9', false)).toBe(4242); + + expect(runCmdDetached).toHaveBeenCalledTimes(2); + expect(runCmdDetached).toHaveBeenNthCalledWith(1, 'emulator', ['-avd', 'Pixel_9']); + expect(runCmdDetached).toHaveBeenNthCalledWith(2, 'osascript', [ + '-l', + 'JavaScript', + '-e', + expect.stringContaining('activateWithOptions'), + '4242', + '30000', + ]); +}); + +test('a headless launch has no window to keep in the background', () => { + createAndroidEmulatorHost('darwin').launch('Pixel_9', true); + + expect(runCmdDetached).toHaveBeenCalledOnce(); + expect(runCmdDetached).toHaveBeenCalledWith('emulator', [ + '-avd', + 'Pixel_9', + '-no-window', + '-no-audio', + ]); +}); + +test('a launch on another host leaves window focus alone', () => { + createAndroidEmulatorHost('linux').launch('Pixel_9', false); + + expect(runCmdDetached).toHaveBeenCalledOnce(); +}); diff --git a/src/platform-runtime-android-emulator-host.ts b/src/platform-runtime-android-emulator-host.ts index 8d8961f263..f688d8465d 100644 --- a/src/platform-runtime-android-emulator-host.ts +++ b/src/platform-runtime-android-emulator-host.ts @@ -3,7 +3,33 @@ import { listLocalDeviceInventory } from '@agent-device/device-selection/device- import { runCmdDetached } from '@agent-device/host-kit/command'; import { stopPidsWithEscalation } from '@agent-device/host-kit/process'; -export function createAndroidEmulatorHost(): DeviceReadinessRuntimeHost['androidEmulator'] { +const FOCUS_RETURN_WINDOW_MS = 30_000; + +// The macOS emulator has no flag to open its window without activating, so this JXA script waits +// for the emulator to become frontmost and reactivates the app that was frontmost before it. +const RETURN_FOCUS_SCRIPT = `ObjC.import('AppKit'); +function run(argv) { + const emulatorPid = Number(argv[0]); + const deadline = Date.now() + Number(argv[1]); + const workspace = $.NSWorkspace.sharedWorkspace; + let previousPid = 0; + while (Date.now() < deadline) { + $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.05)); + const front = workspace.frontmostApplication; + const frontPid = front.isNil() ? 0 : front.processIdentifier; + if (frontPid !== emulatorPid) { + previousPid = frontPid; + continue; + } + const previous = $.NSRunningApplication.runningApplicationWithProcessIdentifier(previousPid); + if (previousPid !== 0 && !previous.isNil()) previous.activateWithOptions(0); + return; + } +}`; + +export function createAndroidEmulatorHost( + hostPlatform: NodeJS.Platform = process.platform, +): DeviceReadinessRuntimeHost['androidEmulator'] { return Object.freeze({ discover: async (request, signal) => { signal.throwIfAborted(); @@ -14,7 +40,18 @@ export function createAndroidEmulatorHost(): DeviceReadinessRuntimeHost['android launch: (avdName, headless) => { const args = ['-avd', avdName]; if (headless) args.push('-no-window', '-no-audio'); - return runCmdDetached('emulator', args); + const pid = runCmdDetached('emulator', args); + if (!headless && hostPlatform === 'darwin') { + runCmdDetached('osascript', [ + '-l', + 'JavaScript', + '-e', + RETURN_FOCUS_SCRIPT, + String(pid), + String(FOCUS_RETURN_WINDOW_MS), + ]); + } + return pid; }, terminate: async (pid) => { await stopPidsWithEscalation({ pids: [pid], termTimeoutMs: 1_500, killTimeoutMs: 1_500 }); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index dde33fd986..ce27ab5cc4 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -66,7 +66,7 @@ agent-device daemon stop --clean Boot and shut down: - `boot` gets the selected target ready without launching an app. It needs an active session or a device selector. You mainly need it when `open` fails because no simulator or emulator is booted. -- Android: `boot --platform android --device ` launches that emulator with a window; add `--headless` to launch it without one. +- Android: `boot --platform android --device ` launches that emulator with a window; add `--headless` to launch it without one. On macOS the window opens behind the app you are using and returns focus to it. - `shutdown` turns off the selected Apple simulator or Android emulator. It refuses the active session's device; use `close --shutdown` to end the session and turn the device off. - `--timeout ` on `boot`, `open `, and `prepare ios-runner` is the startup budget for booting an iOS Simulator or Android emulator (for `prepare ios-runner`, shared with runner preparation). A never-booted iOS Simulator can take several minutes. When the budget runs out, the command fails with `error.details.reason: boot_timeout` and the device keeps booting, so a retry finds it further along. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). From 2cb3c3155b3f86fe19882ab1a977208806db6653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 17:56:07 +0200 Subject: [PATCH 2/5] refactor(android): remove the superseded ensureAndroidEmulatorBooted launch path Production boots emulators through readiness/runtime.ts and the host launch port. This second launch path had no caller outside its own tests. Co-Authored-By: Claude --- .../src/__tests__/emulator-lifecycle.test.ts | 86 +---------- .../src/emulator-lifecycle.ts | 145 +----------------- packages/platform-android/src/mechanics.ts | 6 +- 3 files changed, 5 insertions(+), 232 deletions(-) diff --git a/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts b/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts index a9bf41e3a6..57b3036b2e 100644 --- a/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts +++ b/packages/platform-android/src/__tests__/emulator-lifecycle.test.ts @@ -1,106 +1,24 @@ import assert from 'node:assert/strict'; import { beforeEach, test, vi } from 'vitest'; -import type { DeviceInfo } from '@agent-device/kernel/device'; vi.mock('@agent-device/host-kit/command', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, runCmd: vi.fn(), - runCmdDetached: vi.fn(), - whichCmd: vi.fn(), }; }); -vi.mock('../sdk.ts', () => ({ ensureAndroidSdkPathConfigured: vi.fn(async () => {}) })); import './test-utils/android-host-test-setup.ts'; -import { runCmd, runCmdDetached, whichCmd } from '@agent-device/host-kit/command'; +import { runCmd } from '@agent-device/host-kit/command'; import { AppError } from '@agent-device/kernel/errors'; -import { ensureAndroidEmulatorBooted, waitForAndroidBoot } from '../emulator-lifecycle.ts'; - -const stopped: DeviceInfo = { - platform: 'android', - id: 'Pixel_9_Pro_XL', - name: 'Pixel_9_Pro_XL', - kind: 'emulator', - target: 'mobile', - booted: false, -}; -const running: DeviceInfo = { - ...stopped, - id: 'emulator-5554', - name: 'Pixel 9 Pro XL', - booted: true, -}; +import { waitForAndroidBoot } from '../emulator-lifecycle.ts'; const mockRunCmd = vi.mocked(runCmd); -const mockRunCmdDetached = vi.mocked(runCmdDetached); -const mockWhichCmd = vi.mocked(whichCmd); beforeEach(() => { mockRunCmd.mockReset(); mockRunCmd.mockResolvedValue({ stdout: '1\n', stderr: '', exitCode: 0 }); - mockRunCmdDetached.mockReset(); - mockRunCmdDetached.mockReturnValue(1234); - mockWhichCmd.mockReset(); - mockWhichCmd.mockResolvedValue(true); -}); - -test('emulator boot adopts package-owned local discovery and launches a stopped AVD', async () => { - const requests: unknown[] = []; - const discoveries = [[stopped], [running], [running]]; - const discoverLocal = vi.fn(async (request) => { - requests.push(request); - return discoveries.shift() ?? [running]; - }); - - const device = await ensureAndroidEmulatorBooted( - { avdName: 'Pixel 9 Pro XL', timeoutMs: 5_000, headless: true }, - { discoverLocal }, - ); - - assert.equal(device.id, 'emulator-5554'); - assert.equal(device.booted, true); - assert.deepEqual(requests[0], { platform: 'android', kind: 'emulator' }); - assert.deepEqual(mockRunCmdDetached.mock.calls[0]?.slice(0, 2), [ - 'emulator', - ['-avd', 'Pixel_9_Pro_XL', '-no-window', '-no-audio'], - ]); -}); - -test('emulator boot reuses a matching running emulator without relaunching', async () => { - const discoverLocal = vi.fn(async () => [running]); - - const device = await ensureAndroidEmulatorBooted( - { avdName: 'Pixel_9_Pro_XL', timeoutMs: 5_000, headless: true }, - { discoverLocal }, - ); - - assert.equal(device.id, running.id); - assert.equal(mockRunCmdDetached.mock.calls.length, 0); -}); - -test('emulator boot excludes a running serial outside the resolved Android allowlist', async () => { - const requests: Array<{ androidSerialAllowlist?: string[] }> = []; - const dependencies = { - androidSerialAllowlist: ['emulator-9999'], - discoverLocal: vi.fn(async (request: { androidSerialAllowlist?: string[] }) => { - requests.push(request); - return request.androidSerialAllowlist ? [] : [running]; - }), - }; - - await assert.rejects( - ensureAndroidEmulatorBooted( - { avdName: 'Pixel_9_Pro_XL', timeoutMs: 5_000, headless: true }, - dependencies, - ), - (error: unknown) => - error instanceof Error && 'code' in error && error.code === 'DEVICE_NOT_FOUND', - ); - - assert.deepEqual(requests[0]?.androidSerialAllowlist, ['emulator-9999']); - assert.equal(mockRunCmdDetached.mock.calls.length, 0); }); function bootPropFailure(stdout: string, stderr: string) { diff --git a/packages/platform-android/src/emulator-lifecycle.ts b/packages/platform-android/src/emulator-lifecycle.ts index a3bafc0ef3..72b7acaf67 100644 --- a/packages/platform-android/src/emulator-lifecycle.ts +++ b/packages/platform-android/src/emulator-lifecycle.ts @@ -1,155 +1,14 @@ -import type { DeviceInventoryRequest } from '@agent-device/contracts/device'; -import type { DeviceInfo } from '@agent-device/kernel/device'; import { deviceShellArgv } from '@agent-device/kernel/device-shell'; import { AppError, asAppError } from '@agent-device/kernel/errors'; -import { type ExecResult, runCmdDetached, whichCmd } from '@agent-device/host-kit/command'; -import { Deadline, retryWithPolicy, sleep } from '@agent-device/host-kit/retry'; +import type { ExecResult } from '@agent-device/host-kit/command'; +import { Deadline, retryWithPolicy } from '@agent-device/host-kit/retry'; import { androidAdbInvocation, androidAdbSerialTarget } from './adb-transport.ts'; import { runAndroidHostAdb } from './adb-executor.ts'; import { bootFailureHint, classifyBootFailure } from '@agent-device/provision-kit/boot-diagnostics'; -import { ensureAndroidSdkPathConfigured } from './sdk.ts'; const ANDROID_BOOT_POLL_MS = 1_000; const ANDROID_BOOT_PROP_TIMEOUT_MS = 10_000; -const ANDROID_EMULATOR_BOOT_POLL_MS = 1_000; -const ANDROID_EMULATOR_BOOT_TIMEOUT_MS = 120_000; -const ANDROID_EMULATOR_SERIAL_PREFIX = 'emulator-'; - -export type AndroidEmulatorLifecycleDependencies = Readonly<{ - discoverLocal: (request: DeviceInventoryRequest) => Promise; - androidSerialAllowlist?: readonly string[]; -}>; - -export async function ensureAndroidEmulatorBooted( - params: Readonly<{ - avdName: string; - serial?: string; - timeoutMs?: number; - headless?: boolean; - }>, - dependencies: AndroidEmulatorLifecycleDependencies, -): Promise { - await ensureAndroidSdkPathConfigured(); - const requestedAvdName = params.avdName.trim(); - if (!requestedAvdName) { - throw new AppError('INVALID_ARGS', 'Android emulator boot requires a non-empty AVD name.'); - } - const timeoutMs = params.timeoutMs ?? ANDROID_EMULATOR_BOOT_TIMEOUT_MS; - if (!(await whichCmd('adb'))) throw new AppError('TOOL_MISSING', 'adb not found in PATH'); - if (!(await whichCmd('emulator'))) { - throw new AppError('TOOL_MISSING', 'emulator not found in PATH'); - } - - const available = await discoverAndroidEmulators(dependencies, params.serial); - const selected = findEmulatorByAvdName(available, requestedAvdName, params.serial); - if (!selected) { - throw new AppError('DEVICE_NOT_FOUND', `No Android emulator AVD named ${params.avdName}`, { - requestedAvdName, - availableAvds: available.map((device) => device.name), - hint: 'Run `emulator -list-avds` and pass an existing AVD name to --device.', - }); - } - - const resolvedAvdName = selected.name; - const startedAt = Date.now(); - const runningExisting = isRunningEmulator(selected) ? selected : undefined; - if (!runningExisting) { - const launchArgs = ['-avd', resolvedAvdName]; - if (params.headless) launchArgs.push('-no-window', '-no-audio'); - runCmdDetached('emulator', launchArgs); - } - - const discovered = - runningExisting ?? - (await waitForEmulatorDiscovery({ - dependencies, - avdName: resolvedAvdName, - serial: params.serial, - timeoutMs, - })); - const elapsedMs = Date.now() - startedAt; - await waitForAndroidBoot(discovered.id, Math.max(1_000, timeoutMs - elapsedMs)); - - const refreshed = (await discoverAndroidEmulators(dependencies, params.serial)).find( - (device) => device.id === discovered.id, - ); - return { - ...(refreshed ?? discovered), - name: resolvedAvdName, - booted: true, - }; -} - -async function discoverAndroidEmulators( - dependencies: AndroidEmulatorLifecycleDependencies, - serial?: string, -): Promise { - return await dependencies.discoverLocal({ - platform: 'android', - kind: 'emulator', - ...(serial ? { serial } : {}), - ...(dependencies.androidSerialAllowlist - ? { androidSerialAllowlist: [...dependencies.androidSerialAllowlist] } - : {}), - }); -} - -function findEmulatorByAvdName( - devices: readonly DeviceInfo[], - avdName: string, - serial?: string, -): DeviceInfo | undefined { - const normalizedName = normalizeAndroidDeviceName(avdName); - return devices.find( - (device) => - device.platform === 'android' && - device.kind === 'emulator' && - normalizeAndroidDeviceName(device.name) === normalizedName && - (!serial || !isRunningEmulator(device) || device.id === serial), - ); -} - -function isRunningEmulator(device: DeviceInfo): boolean { - return isAndroidEmulatorSerial(device.id); -} - -function isAndroidEmulatorSerial(serial: string): boolean { - return serial.startsWith(ANDROID_EMULATOR_SERIAL_PREFIX); -} - -function normalizeAndroidDeviceName(value: string): string { - return value.toLowerCase().replaceAll('_', ' ').replaceAll(/\s+/g, ' ').trim(); -} - -async function waitForEmulatorDiscovery(params: { - dependencies: AndroidEmulatorLifecycleDependencies; - avdName: string; - serial?: string; - timeoutMs: number; -}): Promise { - const startedAt = Date.now(); - const pollMs = Math.min( - ANDROID_EMULATOR_BOOT_POLL_MS, - Math.max(50, Math.floor(params.timeoutMs / 20)), - ); - while (Date.now() - startedAt < params.timeoutMs) { - try { - const devices = await discoverAndroidEmulators(params.dependencies, params.serial); - const device = findEmulatorByAvdName(devices, params.avdName, params.serial); - if (device && isRunningEmulator(device)) return device; - } catch { - // Best-effort polling while adb and the emulator process settle. - } - await sleep(pollMs); - } - throw new AppError('COMMAND_FAILED', 'Android emulator did not appear in time', { - avdName: params.avdName, - serial: params.serial, - timeoutMs: params.timeoutMs, - hint: 'Check emulator logs and verify the AVD can start from command line.', - }); -} async function readAndroidBootProp( serial: string, diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index f88e90f761..0407132e33 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -124,11 +124,7 @@ export { type AndroidKeyboardState, } from './device-input-state.ts'; export { androidDeviceChecks, androidToolchainCheck } from './doctor.ts'; -export { - ensureAndroidEmulatorBooted, - waitForAndroidBoot, - type AndroidEmulatorLifecycleDependencies, -} from './emulator-lifecycle.ts'; +export { waitForAndroidBoot } from './emulator-lifecycle.ts'; export { androidFillFailureDetails, androidFillFailureMessage, From 3ad7668d796cc7a62491d815c3f862a920b09dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 18:39:38 +0200 Subject: [PATCH 3/5] fix(android): capture the frontmost app before the emulator launch Read the frontmost app before spawning the emulator, so the watcher has an app to restore even if the emulator activates before its first poll. Bound the watcher by the remaining startup budget instead of a fixed 30 seconds. The launch port is async now because it reads the frontmost app. Co-Authored-By: Claude --- .../contracts/src/device-readiness-runtime.ts | 2 +- .../src/network/runtime.test.ts | 6 ++- .../src/readiness/runtime.test.ts | 24 ++++++++-- .../platform-android/src/readiness/runtime.ts | 6 ++- packages/platform-android/src/runtime.test.ts | 6 ++- .../src/deployment/runtime.test.ts | 6 ++- .../src/network/runtime.test.ts | 6 ++- .../platform-apple/src/runtime.fixtures.ts | 2 +- packages/platform-web/src/runtime.test.ts | 6 ++- .../src/platform-runtime.test.ts | 6 ++- .../application-lifecycle-runtime-fixture.ts | 2 +- ...form-runtime-android-emulator-host.test.ts | 44 +++++++++++++++---- src/platform-runtime-android-emulator-host.ts | 37 ++++++++++++---- 13 files changed, 122 insertions(+), 31 deletions(-) diff --git a/packages/contracts/src/device-readiness-runtime.ts b/packages/contracts/src/device-readiness-runtime.ts index 5382413386..3271d4c019 100644 --- a/packages/contracts/src/device-readiness-runtime.ts +++ b/packages/contracts/src/device-readiness-runtime.ts @@ -38,7 +38,7 @@ export type DeviceReadinessRuntimeHost = Readonly<{ }>; androidEmulator: Readonly<{ discover(request: DeviceInventoryRequest, signal: AbortSignal): Promise; - launch(avdName: string, headless: boolean): number; + launch(avdName: string, headless: boolean, bootBudgetMs: number): Promise; terminate(pid: number): Promise; }>; }>; diff --git a/packages/platform-android/src/network/runtime.test.ts b/packages/platform-android/src/network/runtime.test.ts index f56623eb78..6c0d714e4e 100644 --- a/packages/platform-android/src/network/runtime.test.ts +++ b/packages/platform-android/src/network/runtime.test.ts @@ -195,7 +195,11 @@ function unusedAppLogHost(): Omit< markBooted: () => {}, wasRecentlyObservedBooted: async () => false, }, - androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, + androidEmulator: { + discover: async () => [], + launch: async () => 1, + terminate: async () => {}, + }, }, deviceShutdown: { apple: { diff --git a/packages/platform-android/src/readiness/runtime.test.ts b/packages/platform-android/src/readiness/runtime.test.ts index ef48c4bce9..11ed49629c 100644 --- a/packages/platform-android/src/readiness/runtime.test.ts +++ b/packages/platform-android/src/readiness/runtime.test.ts @@ -33,7 +33,7 @@ test('cancellation interrupts Android boot polling and terminates the emulator l discoveries += 1; return discoveries === 1 ? [stoppedAvd()] : [runningEmulator()]; }, - launch: () => 4242, + launch: async () => 4242, terminate, }, }, @@ -52,6 +52,7 @@ function timedHost(overrides: { bootCompleted: string; discover: () => DeviceInf let now = 1_000; const run = vi.fn(async () => ({ stdout: overrides.bootCompleted, stderr: '', exitCode: 0 })); const terminate = vi.fn(async () => {}); + const launch = vi.fn(async () => 4242); const host = { commands: { which: async () => 'tool', run }, toolchains: { prepare: async () => {} }, @@ -64,12 +65,12 @@ function timedHost(overrides: { bootCompleted: string; discover: () => DeviceInf deviceReadiness: { androidEmulator: { discover: async () => overrides.discover(), - launch: () => 4242, + launch, terminate, }, }, } as unknown as PlatformRuntimeHost; - return { host, run, terminate, elapsed: () => now - 1_000 }; + return { host, run, launch, terminate, elapsed: () => now - 1_000 }; } test('a startup deadline bounds the emulator boot wait and expiry reports boot_timeout without killing the emulator', async () => { @@ -128,6 +129,23 @@ test('expiry of a launched emulator that never reports boot_completed leaves it expect(terminate).not.toHaveBeenCalled(); }); +test('a launched emulator gets the remaining startup budget for its window watcher', async () => { + let discoveries = 0; + const { host, launch } = timedHost({ + bootCompleted: '1', + discover: () => (++discoveries === 1 ? [stoppedAvd()] : [runningEmulator()]), + }); + + await ensureAndroidReady( + host, + stoppedAvd(), + { headless: false, deadlineAtMs: 31_000 }, + new AbortController().signal, + ); + + expect(launch).toHaveBeenCalledWith('Pixel_9', false, 30_000); +}); + test('without a deadline the default 120s boot wait applies', async () => { const { host, elapsed } = timedHost({ bootCompleted: '0', discover: () => [runningEmulator()] }); diff --git a/packages/platform-android/src/readiness/runtime.ts b/packages/platform-android/src/readiness/runtime.ts index 1b9ea6d045..404cb8e83f 100644 --- a/packages/platform-android/src/readiness/runtime.ts +++ b/packages/platform-android/src/readiness/runtime.ts @@ -47,7 +47,11 @@ async function ensureEmulatorReady( const existing = isRunningEmulator(selected) ? selected : undefined; const launchedPid = existing ? undefined - : host.deviceReadiness.androidEmulator.launch(selected.name, input.headless); + : await host.deviceReadiness.androidEmulator.launch( + selected.name, + input.headless, + deadlineAtMs - host.clock.now(), + ); try { const discovered = existing ?? diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 1be6b47804..04b70c4fe8 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -60,7 +60,11 @@ test.each([ markBooted: () => {}, wasRecentlyObservedBooted: async () => false, }, - androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, + androidEmulator: { + discover: async () => [], + launch: async () => 1, + terminate: async () => {}, + }, }, }); const binding = await bindOrdinary(createAndroidPlatformRuntime(host), runtimeDevice); diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index e56a876049..e61d347b0d 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -100,7 +100,11 @@ function deploymentHost( markBooted: () => {}, wasRecentlyObservedBooted: async () => false, }, - androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, + androidEmulator: { + discover: async () => [], + launch: async () => 1, + terminate: async () => {}, + }, }, } as unknown as PlatformRuntimeHost; } diff --git a/packages/platform-apple/src/network/runtime.test.ts b/packages/platform-apple/src/network/runtime.test.ts index 176e943715..cef2922c2e 100644 --- a/packages/platform-apple/src/network/runtime.test.ts +++ b/packages/platform-apple/src/network/runtime.test.ts @@ -201,7 +201,11 @@ function unusedAppLogHost(): Omit< markBooted: () => {}, wasRecentlyObservedBooted: async () => false, }, - androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, + androidEmulator: { + discover: async () => [], + launch: async () => 1, + terminate: async () => {}, + }, }, deviceShutdown: { apple: { diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index 11f29d7fcb..2009265840 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -25,7 +25,7 @@ export function platformRuntimeHostFixture(): PlatformRuntimeHost { }, androidEmulator: { discover: async () => [], - launch: () => 1, + launch: async () => 1, terminate: async () => {}, }, }, diff --git a/packages/platform-web/src/runtime.test.ts b/packages/platform-web/src/runtime.test.ts index 5ba868a300..9138c1e3d0 100644 --- a/packages/platform-web/src/runtime.test.ts +++ b/packages/platform-web/src/runtime.test.ts @@ -398,7 +398,11 @@ function host( markBooted: () => {}, wasRecentlyObservedBooted: async () => false, }, - androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, + androidEmulator: { + discover: async () => [], + launch: async () => 1, + terminate: async () => {}, + }, }, localInteractors: { resolve: async () => interactor }, applicationResources: { diff --git a/packages/provider-webdriver/src/platform-runtime.test.ts b/packages/provider-webdriver/src/platform-runtime.test.ts index 4b5171c35c..110a885879 100644 --- a/packages/provider-webdriver/src/platform-runtime.test.ts +++ b/packages/provider-webdriver/src/platform-runtime.test.ts @@ -411,7 +411,11 @@ function host(run: PlatformRuntimeHost['commands']['run']): PlatformRuntimeHost markBooted: () => {}, wasRecentlyObservedBooted: async () => false, }, - androidEmulator: { discover: async () => [], launch: () => 1, terminate: async () => {} }, + androidEmulator: { + discover: async () => [], + launch: async () => 1, + terminate: async () => {}, + }, }, deviceShutdown: { apple: { diff --git a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts index 19004b0634..f35d812901 100644 --- a/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts +++ b/src/__tests__/daemon-composition/application-lifecycle-runtime-fixture.ts @@ -131,7 +131,7 @@ function fixtureHost( await awaitFixtureReadiness(readyDevice); return await discoverReadyAndroidEmulators(readyDevice); }, - launch: () => 1, + launch: async () => 1, terminate: async () => undefined, }), }), diff --git a/src/platform-runtime-android-emulator-host.test.ts b/src/platform-runtime-android-emulator-host.test.ts index 087fda2e7f..2aa727d321 100644 --- a/src/platform-runtime-android-emulator-host.test.ts +++ b/src/platform-runtime-android-emulator-host.test.ts @@ -1,17 +1,32 @@ import { beforeEach, expect, test, vi } from 'vitest'; -const { runCmdDetached } = vi.hoisted(() => ({ runCmdDetached: vi.fn(() => 4242) })); -vi.mock('@agent-device/host-kit/command', () => ({ runCmdDetached })); +const { runCmd, runCmdDetached } = vi.hoisted(() => ({ + runCmd: vi.fn(async (_cmd: string, args: readonly string[]) => ({ + stdout: args[0] === 'front' ? 'ASN:0x0-0xa26a26:\n' : '"pid"=17044\n', + stderr: '', + exitCode: 0, + })), + runCmdDetached: vi.fn(() => 4242), +})); +vi.mock('@agent-device/host-kit/command', () => ({ runCmd, runCmdDetached })); import { createAndroidEmulatorHost } from './platform-runtime-android-emulator-host.ts'; beforeEach(() => { + runCmd.mockClear(); runCmdDetached.mockClear(); }); -test('a windowed macOS launch hands focus back from the emulator process', () => { - expect(createAndroidEmulatorHost('darwin').launch('Pixel_9', false)).toBe(4242); +test('a windowed macOS launch hands focus back to the app frontmost before launch', async () => { + await expect(createAndroidEmulatorHost('darwin').launch('Pixel_9', false, 90_000)).resolves.toBe( + 4242, + ); + expect(runCmd).toHaveBeenLastCalledWith( + '/usr/bin/lsappinfo', + ['info', '-only', 'pid', 'ASN:0x0-0xa26a26:'], + expect.anything(), + ); expect(runCmdDetached).toHaveBeenCalledTimes(2); expect(runCmdDetached).toHaveBeenNthCalledWith(1, 'emulator', ['-avd', 'Pixel_9']); expect(runCmdDetached).toHaveBeenNthCalledWith(2, 'osascript', [ @@ -20,13 +35,23 @@ test('a windowed macOS launch hands focus back from the emulator process', () => '-e', expect.stringContaining('activateWithOptions'), '4242', - '30000', + '17044', + '90000', ]); }); -test('a headless launch has no window to keep in the background', () => { - createAndroidEmulatorHost('darwin').launch('Pixel_9', true); +test('a macOS launch with no readable frontmost app starts no focus watcher', async () => { + runCmd.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 1 }); + await createAndroidEmulatorHost('darwin').launch('Pixel_9', false, 90_000); + + expect(runCmdDetached).toHaveBeenCalledOnce(); +}); + +test('a headless launch has no window to keep in the background', async () => { + await createAndroidEmulatorHost('darwin').launch('Pixel_9', true, 90_000); + + expect(runCmd).not.toHaveBeenCalled(); expect(runCmdDetached).toHaveBeenCalledOnce(); expect(runCmdDetached).toHaveBeenCalledWith('emulator', [ '-avd', @@ -36,8 +61,9 @@ test('a headless launch has no window to keep in the background', () => { ]); }); -test('a launch on another host leaves window focus alone', () => { - createAndroidEmulatorHost('linux').launch('Pixel_9', false); +test('a launch on another host leaves window focus alone', async () => { + await createAndroidEmulatorHost('linux').launch('Pixel_9', false, 90_000); + expect(runCmd).not.toHaveBeenCalled(); expect(runCmdDetached).toHaveBeenCalledOnce(); }); diff --git a/src/platform-runtime-android-emulator-host.ts b/src/platform-runtime-android-emulator-host.ts index f688d8465d..c3a223b74d 100644 --- a/src/platform-runtime-android-emulator-host.ts +++ b/src/platform-runtime-android-emulator-host.ts @@ -1,28 +1,29 @@ import type { DeviceReadinessRuntimeHost } from '@agent-device/contracts/device-readiness-runtime'; import { listLocalDeviceInventory } from '@agent-device/device-selection/device-inventory-context'; -import { runCmdDetached } from '@agent-device/host-kit/command'; +import { runCmd, runCmdDetached } from '@agent-device/host-kit/command'; import { stopPidsWithEscalation } from '@agent-device/host-kit/process'; -const FOCUS_RETURN_WINDOW_MS = 30_000; +const LSAPPINFO_PID = /"pid"=(\d+)/; // The macOS emulator has no flag to open its window without activating, so this JXA script waits // for the emulator to become frontmost and reactivates the app that was frontmost before it. +// argv: emulator pid, frontmost pid before launch, watch budget in ms. const RETURN_FOCUS_SCRIPT = `ObjC.import('AppKit'); function run(argv) { const emulatorPid = Number(argv[0]); - const deadline = Date.now() + Number(argv[1]); + let previousPid = Number(argv[1]); + const deadline = Date.now() + Number(argv[2]); const workspace = $.NSWorkspace.sharedWorkspace; - let previousPid = 0; while (Date.now() < deadline) { $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.05)); const front = workspace.frontmostApplication; const frontPid = front.isNil() ? 0 : front.processIdentifier; if (frontPid !== emulatorPid) { - previousPid = frontPid; + if (frontPid !== 0) previousPid = frontPid; continue; } const previous = $.NSRunningApplication.runningApplicationWithProcessIdentifier(previousPid); - if (previousPid !== 0 && !previous.isNil()) previous.activateWithOptions(0); + if (!previous.isNil()) previous.activateWithOptions(0); return; } }`; @@ -37,18 +38,21 @@ export function createAndroidEmulatorHost( signal.throwIfAborted(); return devices; }, - launch: (avdName, headless) => { + launch: async (avdName, headless, bootBudgetMs) => { const args = ['-avd', avdName]; if (headless) args.push('-no-window', '-no-audio'); + const frontmostPid = + !headless && hostPlatform === 'darwin' ? await readFrontmostAppPid() : undefined; const pid = runCmdDetached('emulator', args); - if (!headless && hostPlatform === 'darwin') { + if (frontmostPid !== undefined) { runCmdDetached('osascript', [ '-l', 'JavaScript', '-e', RETURN_FOCUS_SCRIPT, String(pid), - String(FOCUS_RETURN_WINDOW_MS), + String(frontmostPid), + String(bootBudgetMs), ]); } return pid; @@ -58,3 +62,18 @@ export function createAndroidEmulatorHost( }, }); } + +async function readFrontmostAppPid(): Promise { + const front = await runCmd('/usr/bin/lsappinfo', ['front'], { + allowFailure: true, + timeoutMs: 2_000, + }); + const asn = front.stdout.trim(); + if (!asn) return undefined; + const info = await runCmd('/usr/bin/lsappinfo', ['info', '-only', 'pid', asn], { + allowFailure: true, + timeoutMs: 2_000, + }); + const pid = LSAPPINFO_PID.exec(info.stdout)?.[1]; + return pid === undefined ? undefined : Number(pid); +} From d5562c845b5de64112511a678946d819d86b6273 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 18:46:49 +0200 Subject: [PATCH 4/5] fix(android): keep the emulator launch independent of the frontmost-app read runCmd rejects on a timeout or a missing tool even with allowFailure, so a failed lsappinfo read stopped the AVD from booting. Treat the read as best-effort, give it a 1 s budget, and say in the docs that open takes the same route as boot. Co-Authored-By: Claude --- src/platform-runtime-android-emulator-host.test.ts | 11 +++++++++++ src/platform-runtime-android-emulator-host.ts | 8 +++++--- website/docs/docs/commands.md | 2 +- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/platform-runtime-android-emulator-host.test.ts b/src/platform-runtime-android-emulator-host.test.ts index 2aa727d321..e614240532 100644 --- a/src/platform-runtime-android-emulator-host.test.ts +++ b/src/platform-runtime-android-emulator-host.test.ts @@ -48,6 +48,17 @@ test('a macOS launch with no readable frontmost app starts no focus watcher', as expect(runCmdDetached).toHaveBeenCalledOnce(); }); +test('a failed frontmost-app read still launches the emulator, without a focus watcher', async () => { + runCmd.mockRejectedValueOnce(new Error('lsappinfo timed out after 1000ms')); + + await expect(createAndroidEmulatorHost('darwin').launch('Pixel_9', false, 90_000)).resolves.toBe( + 4242, + ); + + expect(runCmdDetached).toHaveBeenCalledOnce(); + expect(runCmdDetached).toHaveBeenCalledWith('emulator', ['-avd', 'Pixel_9']); +}); + test('a headless launch has no window to keep in the background', async () => { await createAndroidEmulatorHost('darwin').launch('Pixel_9', true, 90_000); diff --git a/src/platform-runtime-android-emulator-host.ts b/src/platform-runtime-android-emulator-host.ts index c3a223b74d..aaa9a5db57 100644 --- a/src/platform-runtime-android-emulator-host.ts +++ b/src/platform-runtime-android-emulator-host.ts @@ -42,7 +42,9 @@ export function createAndroidEmulatorHost( const args = ['-avd', avdName]; if (headless) args.push('-no-window', '-no-audio'); const frontmostPid = - !headless && hostPlatform === 'darwin' ? await readFrontmostAppPid() : undefined; + !headless && hostPlatform === 'darwin' + ? await readFrontmostAppPid().catch(() => undefined) + : undefined; const pid = runCmdDetached('emulator', args); if (frontmostPid !== undefined) { runCmdDetached('osascript', [ @@ -66,13 +68,13 @@ export function createAndroidEmulatorHost( async function readFrontmostAppPid(): Promise { const front = await runCmd('/usr/bin/lsappinfo', ['front'], { allowFailure: true, - timeoutMs: 2_000, + timeoutMs: 1_000, }); const asn = front.stdout.trim(); if (!asn) return undefined; const info = await runCmd('/usr/bin/lsappinfo', ['info', '-only', 'pid', asn], { allowFailure: true, - timeoutMs: 2_000, + timeoutMs: 1_000, }); const pid = LSAPPINFO_PID.exec(info.stdout)?.[1]; return pid === undefined ? undefined : Number(pid); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ce27ab5cc4..b84fab2389 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -66,7 +66,7 @@ agent-device daemon stop --clean Boot and shut down: - `boot` gets the selected target ready without launching an app. It needs an active session or a device selector. You mainly need it when `open` fails because no simulator or emulator is booted. -- Android: `boot --platform android --device ` launches that emulator with a window; add `--headless` to launch it without one. On macOS the window opens behind the app you are using and returns focus to it. +- Android: `boot --platform android --device ` launches that emulator with a window; add `--headless` to launch it without one. On macOS the window opens behind the focused app, which keeps focus (also when `open` boots a stopped AVD). - `shutdown` turns off the selected Apple simulator or Android emulator. It refuses the active session's device; use `close --shutdown` to end the session and turn the device off. - `--timeout ` on `boot`, `open `, and `prepare ios-runner` is the startup budget for booting an iOS Simulator or Android emulator (for `prepare ios-runner`, shared with runner preparation). A never-booted iOS Simulator can take several minutes. When the budget runs out, the command fails with `error.details.reason: boot_timeout` and the device keeps booting, so a retry finds it further along. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device). From 8e1b826052c9ac08d6298654e584ca1c1fbfa57c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 15 Oct 2026 08:11:39 +0200 Subject: [PATCH 5/5] docs(android): describe the macOS emulator focus return as best-effort Co-Authored-By: Claude --- website/docs/docs/commands.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b84fab2389..8d252aaf3a 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -66,7 +66,7 @@ agent-device daemon stop --clean Boot and shut down: - `boot` gets the selected target ready without launching an app. It needs an active session or a device selector. You mainly need it when `open` fails because no simulator or emulator is booted. -- Android: `boot --platform android --device ` launches that emulator with a window; add `--headless` to launch it without one. On macOS the window opens behind the focused app, which keeps focus (also when `open` boots a stopped AVD). +- Android: `boot --platform android --device ` launches that emulator with a window; add `--headless` to launch it without one. On macOS the window opens behind the focused app, which keeps focus (also when `open` boots a stopped AVD). This is best-effort: the emulator can still take focus if the focused app cannot be read, or if its window appears after the startup budget runs out. - `shutdown` turns off the selected Apple simulator or Android emulator. It refuses the active session's device; use `close --shutdown` to end the session and turn the device off. - `--timeout ` on `boot`, `open `, and `prepare ios-runner` is the startup budget for booting an iOS Simulator or Android emulator (for `prepare ios-runner`, shared with runner preparation). A never-booted iOS Simulator can take several minutes. When the budget runs out, the command fails with `error.details.reason: boot_timeout` and the device keeps booting, so a retry finds it further along. See [Sessions](/docs/sessions#wait-for-a-slow-or-busy-device).