Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/contracts/src/device-readiness-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type DeviceReadinessRuntimeHost = Readonly<{
}>;
androidEmulator: Readonly<{
discover(request: DeviceInventoryRequest, signal: AbortSignal): Promise<readonly DeviceInfo[]>;
launch(avdName: string, headless: boolean): number;
launch(avdName: string, headless: boolean, bootBudgetMs: number): Promise<number>;
terminate(pid: number): Promise<void>;
}>;
}>;
86 changes: 2 additions & 84 deletions packages/platform-android/src/__tests__/emulator-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@agent-device/host-kit/command')>();
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) {
Expand Down
145 changes: 2 additions & 143 deletions packages/platform-android/src/emulator-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -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<readonly DeviceInfo[]>;
androidSerialAllowlist?: readonly string[];
}>;

export async function ensureAndroidEmulatorBooted(
params: Readonly<{
avdName: string;
serial?: string;
timeoutMs?: number;
headless?: boolean;
}>,
dependencies: AndroidEmulatorLifecycleDependencies,
): Promise<DeviceInfo> {
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<readonly DeviceInfo[]> {
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<DeviceInfo> {
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,
Expand Down
6 changes: 1 addition & 5 deletions packages/platform-android/src/mechanics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/platform-android/src/network/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
24 changes: 21 additions & 3 deletions packages/platform-android/src/readiness/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
},
Expand All @@ -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 () => {} },
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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()] });

Expand Down
6 changes: 5 additions & 1 deletion packages/platform-android/src/readiness/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand Down
6 changes: 5 additions & 1 deletion packages/platform-android/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion packages/platform-apple/src/deployment/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading