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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ App state:
- Built-in retries cover transient runner connection failures, AX snapshot hiccups, and Android UI dumps.
- For snapshot issues (missing elements), compare with `--raw` flag for unaltered output and scope with `-s "<label>"`.

Boot diagnostics:
- Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs.
- Reason codes: `BOOT_TIMEOUT`, `DEVICE_UNAVAILABLE`, `DEVICE_OFFLINE`, `PERMISSION_DENIED`, `TOOL_MISSING`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.

## App resolution
- Bundle/package identifiers are accepted directly (e.g., `com.apple.Preferences`).
- Human-readable names are resolved when possible (e.g., `Settings`).
Expand All @@ -200,6 +205,14 @@ App state:
pnpm test
```

Useful local checks:

```bash
pnpm typecheck
pnpm test:unit
pnpm test:smoke
```

## Build

```bash
Expand All @@ -209,6 +222,7 @@ pnpm build
Environment selectors:
- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554`
- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=<udid>`
- `AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS=<ms>` to adjust iOS simulator boot timeout (default: `120000`, minimum: `5000`).

Test screenshots are written to:
- `test/screenshots/android-settings.png`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"prepack": "pnpm build:node && pnpm build:axsnapshot",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test",
"test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts",
"test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
"test:smoke": "node --test test/integration/smoke-*.test.ts",
"test:integration": "node --test test/integration/*.test.ts"
},
Expand Down
30 changes: 30 additions & 0 deletions src/platforms/__tests__/boot-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classifyBootFailure } from '../boot-diagnostics.ts';
import { AppError } from '../../utils/errors.ts';

test('classifyBootFailure maps timeout errors', () => {
const reason = classifyBootFailure({ message: 'bootstatus timed out after 120s' });
assert.equal(reason, 'BOOT_TIMEOUT');
});

test('classifyBootFailure maps adb offline errors', () => {
const reason = classifyBootFailure({ stderr: 'error: device offline' });
assert.equal(reason, 'DEVICE_OFFLINE');
});

test('classifyBootFailure maps tool missing from AppError code', () => {
const reason = classifyBootFailure({
error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
});
assert.equal(reason, 'TOOL_MISSING');
});

test('classifyBootFailure reads stderr from AppError details', () => {
const reason = classifyBootFailure({
error: new AppError('COMMAND_FAILED', 'adb failed', {
stderr: 'error: device unauthorized',
}),
});
assert.equal(reason, 'PERMISSION_DENIED');
});
174 changes: 133 additions & 41 deletions src/platforms/android/devices.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
import { runCmd, whichCmd } from '../../utils/exec.ts';
import { AppError } from '../../utils/errors.ts';
import type { ExecResult } from '../../utils/exec.ts';
import { AppError, asAppError } from '../../utils/errors.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
import { classifyBootFailure } from '../boot-diagnostics.ts';

const EMULATOR_SERIAL_PREFIX = 'emulator-';
const ANDROID_BOOT_POLL_MS = 1000;

function adbArgs(serial: string, args: string[]): string[] {
return ['-s', serial, ...args];
}

function isEmulatorSerial(serial: string): boolean {
return serial.startsWith(EMULATOR_SERIAL_PREFIX);
}

async function readAndroidBootProp(serial: string): Promise<ExecResult> {
return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), {
allowFailure: true,
});
}

async function resolveAndroidDeviceName(serial: string, rawModel: string): Promise<string> {
const modelName = rawModel.replace(/_/g, ' ').trim();
if (!isEmulatorSerial(serial)) return modelName || serial;
const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
allowFailure: true,
});
const avdName = avd.stdout.trim();
if (avd.exitCode === 0 && avdName) {
return avdName.replace(/_/g, ' ');
}
return modelName || serial;
}

export async function listAndroidDevices(): Promise<DeviceInfo[]> {
const adbAvailable = await whichCmd('adb');
Expand All @@ -10,62 +43,121 @@ export async function listAndroidDevices(): Promise<DeviceInfo[]> {

const result = await runCmd('adb', ['devices', '-l']);
const lines = result.stdout.split('\n').map((l: string) => l.trim());
const devices: DeviceInfo[] = [];
const entries = lines
.filter((line) => line.length > 0 && !line.startsWith('List of devices'))
.map((line) => line.split(/\s+/))
.filter((parts) => parts[1] === 'device')
.map((parts) => ({
serial: parts[0],
rawModel: (parts.find((p: string) => p.startsWith('model:')) ?? '').replace('model:', ''),
}));

for (const line of lines) {
if (!line || line.startsWith('List of devices')) continue;
const parts = line.split(/\s+/);
const serial = parts[0];
const state = parts[1];
if (state !== 'device') continue;

const modelPart = parts.find((p: string) => p.startsWith('model:')) ?? '';
const rawModel = modelPart.replace('model:', '').replace(/_/g, ' ').trim();
let name = rawModel || serial;

if (serial.startsWith('emulator-')) {
const avd = await runCmd('adb', ['-s', serial, 'emu', 'avd', 'name'], {
allowFailure: true,
});
const avdName = (avd.stdout as string).trim();
if (avd.exitCode === 0 && avdName) {
name = avdName.replace(/_/g, ' ');
}
}

const booted = await isAndroidBooted(serial);

devices.push({
const devices = await Promise.all(entries.map(async ({ serial, rawModel }) => {
const [name, booted] = await Promise.all([
resolveAndroidDeviceName(serial, rawModel),
isAndroidBooted(serial),
]);
return {
platform: 'android',
id: serial,
name,
kind: serial.startsWith('emulator-') ? 'emulator' : 'device',
kind: isEmulatorSerial(serial) ? 'emulator' : 'device',
booted,
});
}
} satisfies DeviceInfo;
}));

return devices;
}

export async function isAndroidBooted(serial: string): Promise<boolean> {
try {
const result = await runCmd('adb', ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], {
allowFailure: true,
});
return (result.stdout as string).trim() === '1';
const result = await readAndroidBootProp(serial);
return result.stdout.trim() === '1';
} catch {
return false;
}
}

export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await isAndroidBooted(serial)) return;
await new Promise((resolve) => setTimeout(resolve, 1000));
const deadline = Deadline.fromTimeoutMs(timeoutMs);
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / ANDROID_BOOT_POLL_MS));
let lastBootResult: ExecResult | undefined;
let timedOut = false;
try {
await retryWithPolicy(
async ({ deadline: attemptDeadline }) => {
if (attemptDeadline?.isExpired()) {
timedOut = true;
throw new AppError('COMMAND_FAILED', 'Android boot deadline exceeded', {
serial,
timeoutMs,
elapsedMs: deadline.elapsedMs(),
message: 'timeout',
});
}
const result = await readAndroidBootProp(serial);
lastBootResult = result;
if (result.stdout.trim() === '1') return;
throw new AppError('COMMAND_FAILED', 'Android device is still booting', {
serial,
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode,
});
},
{
maxAttempts,
baseDelayMs: ANDROID_BOOT_POLL_MS,
maxDelayMs: ANDROID_BOOT_POLL_MS,
jitter: 0,
shouldRetry: (error) => {
const reason = classifyBootFailure({
error,
stdout: lastBootResult?.stdout,
stderr: lastBootResult?.stderr,
});
return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING' && reason !== 'BOOT_TIMEOUT';
},
},
{ deadline },
);
} catch (error) {
const appErr = asAppError(error);
const stdout = lastBootResult?.stdout;
const stderr = lastBootResult?.stderr;
const exitCode = lastBootResult?.exitCode;
const reason = classifyBootFailure({
error,
stdout,
stderr,
});
const baseDetails = {
serial,
timeoutMs,
elapsedMs: deadline.elapsedMs(),
reason,
stdout,
stderr,
exitCode,
};
if (timedOut || reason === 'BOOT_TIMEOUT') {
throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
}
if (appErr.code === 'TOOL_MISSING' || reason === 'TOOL_MISSING') {
throw new AppError('TOOL_MISSING', appErr.message, {
...baseDetails,
...(appErr.details ?? {}),
});
}
if (reason === 'PERMISSION_DENIED' || reason === 'DEVICE_UNAVAILABLE' || reason === 'DEVICE_OFFLINE') {
throw new AppError('COMMAND_FAILED', appErr.message, {
...baseDetails,
...(appErr.details ?? {}),
});
}
throw new AppError(appErr.code, appErr.message, {
...baseDetails,
...(appErr.details ?? {}),
}, appErr.cause);
}
throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', {
serial,
timeoutMs,
});
}
67 changes: 67 additions & 0 deletions src/platforms/boot-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { asAppError } from '../utils/errors.ts';

export type BootFailureReason =
| 'BOOT_TIMEOUT'
| 'DEVICE_UNAVAILABLE'
| 'DEVICE_OFFLINE'
| 'PERMISSION_DENIED'
| 'TOOL_MISSING'
| 'BOOT_COMMAND_FAILED'
| 'UNKNOWN';

export function classifyBootFailure(input: {
error?: unknown;
message?: string;
stdout?: string;
stderr?: string;
}): BootFailureReason {
const appErr = input.error ? asAppError(input.error) : null;
if (appErr?.code === 'TOOL_MISSING') return 'TOOL_MISSING';
const details = (appErr?.details ?? {}) as Record<string, unknown>;
const detailMessage = typeof details.message === 'string' ? details.message : undefined;
const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined;
const detailStderr = typeof details.stderr === 'string' ? details.stderr : undefined;
const nestedBoot = details.boot && typeof details.boot === 'object'
? (details.boot as Record<string, unknown>)
: null;
const nestedBootstatus = details.bootstatus && typeof details.bootstatus === 'object'
? (details.bootstatus as Record<string, unknown>)
: null;

const haystack = [
input.message,
appErr?.message,
input.stdout,
input.stderr,
detailMessage,
detailStdout,
detailStderr,
typeof nestedBoot?.stdout === 'string' ? nestedBoot.stdout : undefined,
typeof nestedBoot?.stderr === 'string' ? nestedBoot.stderr : undefined,
typeof nestedBootstatus?.stdout === 'string' ? nestedBootstatus.stdout : undefined,
typeof nestedBootstatus?.stderr === 'string' ? nestedBootstatus.stderr : undefined,
]
.filter(Boolean)
.join('\n')
.toLowerCase();

if (haystack.includes('timed out') || haystack.includes('timeout')) return 'BOOT_TIMEOUT';
if (
haystack.includes('device not found') ||
haystack.includes('no devices') ||
haystack.includes('unable to locate device') ||
haystack.includes('invalid device')
) {
return 'DEVICE_UNAVAILABLE';
}
if (haystack.includes('offline')) return 'DEVICE_OFFLINE';
if (
haystack.includes('permission denied') ||
haystack.includes('not authorized') ||
haystack.includes('unauthorized')
) {
return 'PERMISSION_DENIED';
}
if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED';
return 'UNKNOWN';
}
Loading
Loading