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/__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, 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 new file mode 100644 index 0000000000..e614240532 --- /dev/null +++ b/src/platform-runtime-android-emulator-host.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, expect, test, vi } from 'vitest'; + +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 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', [ + '-l', + 'JavaScript', + '-e', + expect.stringContaining('activateWithOptions'), + '4242', + '17044', + '90000', + ]); +}); + +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 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); + + expect(runCmd).not.toHaveBeenCalled(); + expect(runCmdDetached).toHaveBeenCalledOnce(); + expect(runCmdDetached).toHaveBeenCalledWith('emulator', [ + '-avd', + 'Pixel_9', + '-no-window', + '-no-audio', + ]); +}); + +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 8d8961f263..aaa9a5db57 100644 --- a/src/platform-runtime-android-emulator-host.ts +++ b/src/platform-runtime-android-emulator-host.ts @@ -1,9 +1,36 @@ 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'; -export function createAndroidEmulatorHost(): DeviceReadinessRuntimeHost['androidEmulator'] { +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]); + let previousPid = Number(argv[1]); + const deadline = Date.now() + Number(argv[2]); + const workspace = $.NSWorkspace.sharedWorkspace; + 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) { + if (frontPid !== 0) previousPid = frontPid; + continue; + } + const previous = $.NSRunningApplication.runningApplicationWithProcessIdentifier(previousPid); + if (!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(); @@ -11,13 +38,44 @@ export function createAndroidEmulatorHost(): DeviceReadinessRuntimeHost['android signal.throwIfAborted(); return devices; }, - launch: (avdName, headless) => { + launch: async (avdName, headless, bootBudgetMs) => { const args = ['-avd', avdName]; if (headless) args.push('-no-window', '-no-audio'); - return runCmdDetached('emulator', args); + const frontmostPid = + !headless && hostPlatform === 'darwin' + ? await readFrontmostAppPid().catch(() => undefined) + : undefined; + const pid = runCmdDetached('emulator', args); + if (frontmostPid !== undefined) { + runCmdDetached('osascript', [ + '-l', + 'JavaScript', + '-e', + RETURN_FOCUS_SCRIPT, + String(pid), + String(frontmostPid), + String(bootBudgetMs), + ]); + } + return pid; }, terminate: async (pid) => { await stopPidsWithEscalation({ pids: [pid], termTimeoutMs: 1_500, killTimeoutMs: 1_500 }); }, }); } + +async function readFrontmostAppPid(): Promise { + const front = await runCmd('/usr/bin/lsappinfo', ['front'], { + allowFailure: true, + 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: 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 dde33fd986..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. +- 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).