Skip to content

Commit 3ddd3a1

Browse files
authored
Complete issue #39 boot diagnostics, telemetry, and boot command (#46)
* Complete issue #39 phase diagnostics telemetry and boot command * Address review findings for boot diagnostics and command gating * Use agent-device boot preflight in iOS CI workflow * Run iOS boot preflight via source CLI in CI
1 parent 70ab1a8 commit 3ddd3a1

16 files changed

Lines changed: 451 additions & 78 deletions

File tree

.github/workflows/ios.yml

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -52,37 +52,13 @@ jobs:
5252
- name: Build iOS integration artifacts
5353
run: pnpm build:xcuitest
5454

55-
- name: Wait for iOS simulator boot
55+
- name: Boot preflight via agent-device
5656
run: |
5757
set -euo pipefail
58-
59-
wait_boot() {
60-
local deadline=$(( $(date +%s) + 180 ))
61-
while [ "$(date +%s)" -lt "$deadline" ]; do
62-
if xcrun simctl list devices "$IOS_UDID" | grep -q "(Booted)"; then
63-
return 0
64-
fi
65-
sleep 2
66-
done
67-
return 1
68-
}
69-
70-
if wait_boot; then
71-
exit 0
72-
fi
73-
74-
echo "Initial simulator boot wait timed out; retrying boot once..."
75-
xcrun simctl shutdown "$IOS_UDID" || true
76-
xcrun simctl boot "$IOS_UDID" || true
77-
78-
if wait_boot; then
79-
exit 0
80-
fi
81-
82-
echo "Simulator failed to become ready after retry. Collecting diagnostics..."
83-
xcrun simctl list devices || true
84-
xcrun simctl list runtimes || true
85-
exit 1
58+
node --experimental-strip-types src/bin.ts boot --platform ios --udid "$IOS_UDID" --json
59+
env:
60+
AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000"
61+
AGENT_DEVICE_RETRY_LOGS: "1"
8662

8763
- name: Run iOS integration test
8864
env:

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ dist/
44
.DS_Store
55
*.log
66
test/screenshots/*.png
7+
test/artifacts/
78
.build/
89
.swiftpm/
910
DerivedData/

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Coordinates:
7575
- X increases to the right, Y increases downward.
7676

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

187187
Boot diagnostics:
188188
- Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs.
189-
- Reason codes: `BOOT_TIMEOUT`, `DEVICE_UNAVAILABLE`, `DEVICE_OFFLINE`, `PERMISSION_DENIED`, `TOOL_MISSING`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
189+
- Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
190190
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.
191+
- Use `agent-device boot --platform ios|android` for explicit CI preflight readiness checks.
192+
- Set `AGENT_DEVICE_RETRY_LOGS=1` to print structured retry telemetry (attempt, phase, delay, elapsed/remaining deadline, reason).
191193

192194
## App resolution
193195
- Bundle/package identifiers are accepted directly (e.g., `com.apple.Preferences`).

src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ export async function runCli(argv: string[]): Promise<void> {
105105
if (logTailStopper) logTailStopper();
106106
return;
107107
}
108+
if (command === 'boot') {
109+
const platform = (response.data as any)?.platform ?? 'unknown';
110+
const device = (response.data as any)?.device ?? (response.data as any)?.id ?? 'unknown';
111+
process.stdout.write(`Boot ready: ${device} (${platform})\n`);
112+
if (logTailStopper) logTailStopper();
113+
return;
114+
}
108115
if (command === 'click') {
109116
const ref = (response.data as any)?.ref ?? '';
110117
const x = (response.data as any)?.x;

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ test('iOS simulator + Android commands reject iOS devices', () => {
3737
'app-switcher',
3838
'apps',
3939
'back',
40+
'boot',
4041
'click',
4142
'close',
4243
'fill',

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
1919
'app-switcher': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2020
apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2121
back: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
22+
boot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2223
click: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2324
close: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2425
fill: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import fs from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { handleSessionCommands } from '../session.ts';
7+
import { SessionStore } from '../../session-store.ts';
8+
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
9+
10+
function makeSessionStore(): SessionStore {
11+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-handler-'));
12+
return new SessionStore(path.join(root, 'sessions'));
13+
}
14+
15+
function makeSession(name: string, device: SessionState['device']): SessionState {
16+
return {
17+
name,
18+
device,
19+
createdAt: Date.now(),
20+
actions: [],
21+
};
22+
}
23+
24+
const noopInvoke = async (_req: DaemonRequest): Promise<DaemonResponse> => ({ ok: true, data: {} });
25+
26+
test('boot requires session or explicit selector', async () => {
27+
const sessionStore = makeSessionStore();
28+
const response = await handleSessionCommands({
29+
req: {
30+
token: 't',
31+
session: 'default',
32+
command: 'boot',
33+
positionals: [],
34+
flags: {},
35+
},
36+
sessionName: 'default',
37+
logPath: path.join(os.tmpdir(), 'daemon.log'),
38+
sessionStore,
39+
invoke: noopInvoke,
40+
ensureReady: async () => {},
41+
});
42+
assert.ok(response);
43+
assert.equal(response?.ok, false);
44+
if (response && !response.ok) {
45+
assert.equal(response.error.code, 'INVALID_ARGS');
46+
}
47+
});
48+
49+
test('boot rejects unsupported iOS device kind', async () => {
50+
const sessionStore = makeSessionStore();
51+
const sessionName = 'ios-device-session';
52+
sessionStore.set(
53+
sessionName,
54+
makeSession(sessionName, {
55+
platform: 'ios',
56+
id: 'ios-device-1',
57+
name: 'iPhone Device',
58+
kind: 'device',
59+
booted: true,
60+
}),
61+
);
62+
const response = await handleSessionCommands({
63+
req: {
64+
token: 't',
65+
session: sessionName,
66+
command: 'boot',
67+
positionals: [],
68+
flags: {},
69+
},
70+
sessionName,
71+
logPath: path.join(os.tmpdir(), 'daemon.log'),
72+
sessionStore,
73+
invoke: noopInvoke,
74+
ensureReady: async () => {
75+
throw new Error('ensureReady should not be called for unsupported boot');
76+
},
77+
});
78+
assert.ok(response);
79+
assert.equal(response?.ok, false);
80+
if (response && !response.ok) {
81+
assert.equal(response.error.code, 'UNSUPPORTED_OPERATION');
82+
}
83+
});
84+
85+
test('boot succeeds for supported device in session', async () => {
86+
const sessionStore = makeSessionStore();
87+
const sessionName = 'android-session';
88+
sessionStore.set(
89+
sessionName,
90+
makeSession(sessionName, {
91+
platform: 'android',
92+
id: 'emulator-5554',
93+
name: 'Pixel Emulator',
94+
kind: 'emulator',
95+
booted: true,
96+
}),
97+
);
98+
let ensureCalls = 0;
99+
const response = await handleSessionCommands({
100+
req: {
101+
token: 't',
102+
session: sessionName,
103+
command: 'boot',
104+
positionals: [],
105+
flags: {},
106+
},
107+
sessionName,
108+
logPath: path.join(os.tmpdir(), 'daemon.log'),
109+
sessionStore,
110+
invoke: noopInvoke,
111+
ensureReady: async () => {
112+
ensureCalls += 1;
113+
},
114+
});
115+
assert.ok(response);
116+
assert.equal(response?.ok, true);
117+
assert.equal(ensureCalls, 1);
118+
if (response && response.ok) {
119+
assert.equal(response.data?.platform, 'android');
120+
assert.equal(response.data?.booted, true);
121+
}
122+
});

src/daemon/handlers/session.ts

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,19 @@ export async function handleSessionCommands(params: {
2121
sessionStore: SessionStore;
2222
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
2323
dispatch?: typeof dispatchCommand;
24+
ensureReady?: typeof ensureDeviceReady;
2425
}): Promise<DaemonResponse | null> {
25-
const { req, sessionName, logPath, sessionStore, invoke, dispatch: dispatchOverride } = params;
26+
const {
27+
req,
28+
sessionName,
29+
logPath,
30+
sessionStore,
31+
invoke,
32+
dispatch: dispatchOverride,
33+
ensureReady: ensureReadyOverride,
34+
} = params;
2635
const dispatch = dispatchOverride ?? dispatchCommand;
36+
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
2737
const command = req.command;
2838

2939
if (command === 'session_list') {
@@ -82,7 +92,7 @@ export async function handleSessionCommands(params: {
8292
};
8393
}
8494
const device = session?.device ?? (await resolveTargetDevice(flags));
85-
await ensureDeviceReady(device);
95+
await ensureReady(device);
8696
if (!isCommandSupportedOnDevice('apps', device)) {
8797
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
8898
}
@@ -106,11 +116,40 @@ export async function handleSessionCommands(params: {
106116
return { ok: true, data: { apps } };
107117
}
108118

119+
if (command === 'boot') {
120+
const session = sessionStore.get(sessionName);
121+
const flags = req.flags ?? {};
122+
if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) {
123+
return {
124+
ok: false,
125+
error: {
126+
code: 'INVALID_ARGS',
127+
message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
128+
},
129+
};
130+
}
131+
const device = session?.device ?? (await resolveTargetDevice(flags));
132+
if (!isCommandSupportedOnDevice('boot', device)) {
133+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
134+
}
135+
await ensureReady(device);
136+
return {
137+
ok: true,
138+
data: {
139+
platform: device.platform,
140+
device: device.name,
141+
id: device.id,
142+
kind: device.kind,
143+
booted: true,
144+
},
145+
};
146+
}
147+
109148
if (command === 'appstate') {
110149
const session = sessionStore.get(sessionName);
111150
const flags = req.flags ?? {};
112151
const device = session?.device ?? (await resolveTargetDevice(flags));
113-
await ensureDeviceReady(device);
152+
await ensureReady(device);
114153
if (device.platform === 'ios') {
115154
if (session?.appBundleId) {
116155
return {
Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,51 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { classifyBootFailure } from '../boot-diagnostics.ts';
3+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
44
import { AppError } from '../../utils/errors.ts';
55

66
test('classifyBootFailure maps timeout errors', () => {
7-
const reason = classifyBootFailure({ message: 'bootstatus timed out after 120s' });
8-
assert.equal(reason, 'BOOT_TIMEOUT');
7+
const reason = classifyBootFailure({
8+
message: 'bootstatus timed out after 120s',
9+
context: { platform: 'ios', phase: 'boot' },
10+
});
11+
assert.equal(reason, 'IOS_BOOT_TIMEOUT');
912
});
1013

1114
test('classifyBootFailure maps adb offline errors', () => {
12-
const reason = classifyBootFailure({ stderr: 'error: device offline' });
13-
assert.equal(reason, 'DEVICE_OFFLINE');
15+
const reason = classifyBootFailure({
16+
stderr: 'error: device offline',
17+
context: { platform: 'android', phase: 'transport' },
18+
});
19+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
1420
});
1521

1622
test('classifyBootFailure maps tool missing from AppError code', () => {
1723
const reason = classifyBootFailure({
1824
error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
25+
context: { platform: 'android', phase: 'transport' },
1926
});
20-
assert.equal(reason, 'TOOL_MISSING');
27+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
2128
});
2229

2330
test('classifyBootFailure reads stderr from AppError details', () => {
2431
const reason = classifyBootFailure({
2532
error: new AppError('COMMAND_FAILED', 'adb failed', {
2633
stderr: 'error: device unauthorized',
2734
}),
35+
context: { platform: 'android', phase: 'transport' },
36+
});
37+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
38+
});
39+
40+
test('bootFailureHint returns actionable guidance', () => {
41+
const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
42+
assert.equal(hint.includes('xcodebuild logs'), true);
43+
});
44+
45+
test('connect phase does not classify non-timeout errors as connect timeout', () => {
46+
const reason = classifyBootFailure({
47+
message: 'Runner returned malformed JSON payload',
48+
context: { platform: 'ios', phase: 'connect' },
2849
});
29-
assert.equal(reason, 'PERMISSION_DENIED');
50+
assert.equal(reason, 'BOOT_COMMAND_FAILED');
3051
});

0 commit comments

Comments
 (0)