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
34 changes: 5 additions & 29 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,37 +52,13 @@ jobs:
- name: Build iOS integration artifacts
run: pnpm build:xcuitest

- name: Wait for iOS simulator boot
- name: Boot preflight via agent-device
run: |
set -euo pipefail

wait_boot() {
local deadline=$(( $(date +%s) + 180 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
if xcrun simctl list devices "$IOS_UDID" | grep -q "(Booted)"; then
return 0
fi
sleep 2
done
return 1
}

if wait_boot; then
exit 0
fi

echo "Initial simulator boot wait timed out; retrying boot once..."
xcrun simctl shutdown "$IOS_UDID" || true
xcrun simctl boot "$IOS_UDID" || true

if wait_boot; then
exit 0
fi

echo "Simulator failed to become ready after retry. Collecting diagnostics..."
xcrun simctl list devices || true
xcrun simctl list runtimes || true
exit 1
node --experimental-strip-types src/bin.ts boot --platform ios --udid "$IOS_UDID" --json
env:
AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000"
AGENT_DEVICE_RETRY_LOGS: "1"

- name: Run iOS integration test
env:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ dist/
.DS_Store
*.log
test/screenshots/*.png
test/artifacts/
.build/
.swiftpm/
DerivedData/
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Coordinates:
- X increases to the right, Y increases downward.

## Command Index
- `open`, `close`, `home`, `back`, `app-switcher`
- `boot`, `open`, `close`, `home`, `back`, `app-switcher`
- `snapshot`, `find`, `get`
- `click`, `focus`, `type`, `fill`, `press`, `long-press`, `scroll`, `scrollintoview`, `is`
- `alert`, `wait`, `screenshot`
Expand Down Expand Up @@ -186,8 +186,10 @@ App state:

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`.
- Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.
- Use `agent-device boot --platform ios|android` for explicit CI preflight readiness checks.
- Set `AGENT_DEVICE_RETRY_LOGS=1` to print structured retry telemetry (attempt, phase, delay, elapsed/remaining deadline, reason).

## App resolution
- Bundle/package identifiers are accepted directly (e.g., `com.apple.Preferences`).
Expand Down
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ export async function runCli(argv: string[]): Promise<void> {
if (logTailStopper) logTailStopper();
return;
}
if (command === 'boot') {
const platform = (response.data as any)?.platform ?? 'unknown';
const device = (response.data as any)?.device ?? (response.data as any)?.id ?? 'unknown';
process.stdout.write(`Boot ready: ${device} (${platform})\n`);
if (logTailStopper) logTailStopper();
return;
}
if (command === 'click') {
const ref = (response.data as any)?.ref ?? '';
const x = (response.data as any)?.x;
Expand Down
1 change: 1 addition & 0 deletions src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ test('iOS simulator + Android commands reject iOS devices', () => {
'app-switcher',
'apps',
'back',
'boot',
'click',
'close',
'fill',
Expand Down
1 change: 1 addition & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
'app-switcher': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
back: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
boot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
click: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
close: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
fill: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
Expand Down
122 changes: 122 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { handleSessionCommands } from '../session.ts';
import { SessionStore } from '../../session-store.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';

function makeSessionStore(): SessionStore {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-handler-'));
return new SessionStore(path.join(root, 'sessions'));
}

function makeSession(name: string, device: SessionState['device']): SessionState {
return {
name,
device,
createdAt: Date.now(),
actions: [],
};
}

const noopInvoke = async (_req: DaemonRequest): Promise<DaemonResponse> => ({ ok: true, data: {} });

test('boot requires session or explicit selector', async () => {
const sessionStore = makeSessionStore();
const response = await handleSessionCommands({
req: {
token: 't',
session: 'default',
command: 'boot',
positionals: [],
flags: {},
},
sessionName: 'default',
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async () => {},
});
assert.ok(response);
assert.equal(response?.ok, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
}
});

test('boot rejects unsupported iOS device kind', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-device-session';
sessionStore.set(
sessionName,
makeSession(sessionName, {
platform: 'ios',
id: 'ios-device-1',
name: 'iPhone Device',
kind: 'device',
booted: true,
}),
);
const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'boot',
positionals: [],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async () => {
throw new Error('ensureReady should not be called for unsupported boot');
},
});
assert.ok(response);
assert.equal(response?.ok, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'UNSUPPORTED_OPERATION');
}
});

test('boot succeeds for supported device in session', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-session';
sessionStore.set(
sessionName,
makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel Emulator',
kind: 'emulator',
booted: true,
}),
);
let ensureCalls = 0;
const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'boot',
positionals: [],
flags: {},
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
ensureReady: async () => {
ensureCalls += 1;
},
});
assert.ok(response);
assert.equal(response?.ok, true);
assert.equal(ensureCalls, 1);
if (response && response.ok) {
assert.equal(response.data?.platform, 'android');
assert.equal(response.data?.booted, true);
}
});
45 changes: 42 additions & 3 deletions src/daemon/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,19 @@ export async function handleSessionCommands(params: {
sessionStore: SessionStore;
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
dispatch?: typeof dispatchCommand;
ensureReady?: typeof ensureDeviceReady;
}): Promise<DaemonResponse | null> {
const { req, sessionName, logPath, sessionStore, invoke, dispatch: dispatchOverride } = params;
const {
req,
sessionName,
logPath,
sessionStore,
invoke,
dispatch: dispatchOverride,
ensureReady: ensureReadyOverride,
} = params;
const dispatch = dispatchOverride ?? dispatchCommand;
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
const command = req.command;

if (command === 'session_list') {
Expand Down Expand Up @@ -82,7 +92,7 @@ export async function handleSessionCommands(params: {
};
}
const device = session?.device ?? (await resolveTargetDevice(flags));
await ensureDeviceReady(device);
await ensureReady(device);
if (!isCommandSupportedOnDevice('apps', device)) {
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
}
Expand All @@ -106,11 +116,40 @@ export async function handleSessionCommands(params: {
return { ok: true, data: { apps } };
}

if (command === 'boot') {
const session = sessionStore.get(sessionName);
const flags = req.flags ?? {};
if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) {
return {
ok: false,
error: {
code: 'INVALID_ARGS',
message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
},
};
}
const device = session?.device ?? (await resolveTargetDevice(flags));
if (!isCommandSupportedOnDevice('boot', device)) {
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
}
await ensureReady(device);
return {
ok: true,
data: {
platform: device.platform,
device: device.name,
id: device.id,
kind: device.kind,
booted: true,
},
};
}

if (command === 'appstate') {
const session = sessionStore.get(sessionName);
const flags = req.flags ?? {};
const device = session?.device ?? (await resolveTargetDevice(flags));
await ensureDeviceReady(device);
await ensureReady(device);
if (device.platform === 'ios') {
if (session?.appBundleId) {
return {
Expand Down
35 changes: 28 additions & 7 deletions src/platforms/__tests__/boot-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,51 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { classifyBootFailure } from '../boot-diagnostics.ts';
import { bootFailureHint, 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');
const reason = classifyBootFailure({
message: 'bootstatus timed out after 120s',
context: { platform: 'ios', phase: 'boot' },
});
assert.equal(reason, 'IOS_BOOT_TIMEOUT');
});

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

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

test('classifyBootFailure reads stderr from AppError details', () => {
const reason = classifyBootFailure({
error: new AppError('COMMAND_FAILED', 'adb failed', {
stderr: 'error: device unauthorized',
}),
context: { platform: 'android', phase: 'transport' },
});
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
});

test('bootFailureHint returns actionable guidance', () => {
const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
assert.equal(hint.includes('xcodebuild logs'), true);
});

test('connect phase does not classify non-timeout errors as connect timeout', () => {
const reason = classifyBootFailure({
message: 'Runner returned malformed JSON payload',
context: { platform: 'ios', phase: 'connect' },
});
assert.equal(reason, 'PERMISSION_DENIED');
assert.equal(reason, 'BOOT_COMMAND_FAILED');
});
Loading
Loading