agent-device 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/dist/src/274.js +1 -1
- package/dist/src/bin.js +25 -22
- package/dist/src/daemon.js +15 -11
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +8 -1
- package/src/cli.ts +7 -0
- package/src/core/__tests__/capabilities.test.ts +2 -0
- package/src/core/capabilities.ts +2 -0
- package/src/daemon/__tests__/selectors.test.ts +133 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +64 -0
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
- package/src/daemon/handlers/__tests__/session.test.ts +122 -0
- package/src/daemon/handlers/find.ts +23 -3
- package/src/daemon/handlers/interaction.ts +5 -3
- package/src/daemon/handlers/session.ts +187 -15
- package/src/daemon/selectors.ts +138 -20
- package/src/daemon/snapshot-processing.ts +5 -1
- package/src/platforms/__tests__/boot-diagnostics.test.ts +37 -8
- package/src/platforms/android/__tests__/index.test.ts +17 -0
- package/src/platforms/android/devices.ts +47 -14
- package/src/platforms/android/index.ts +101 -14
- package/src/platforms/boot-diagnostics.ts +78 -17
- package/src/platforms/ios/index.ts +76 -9
- package/src/platforms/ios/runner-client.ts +19 -1
- package/src/utils/__tests__/exec.test.ts +16 -0
- package/src/utils/__tests__/finders.test.ts +34 -0
- package/src/utils/__tests__/retry.test.ts +17 -0
- package/src/utils/args.ts +2 -0
- package/src/utils/exec.ts +39 -0
- package/src/utils/finders.ts +27 -9
- package/src/utils/retry.ts +72 -2
|
@@ -2,11 +2,12 @@ import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
|
2
2
|
import type { ExecResult } from '../../utils/exec.ts';
|
|
3
3
|
import { AppError, asAppError } from '../../utils/errors.ts';
|
|
4
4
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
5
|
-
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
|
|
6
|
-
import { classifyBootFailure } from '../boot-diagnostics.ts';
|
|
5
|
+
import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
|
|
6
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
7
7
|
|
|
8
8
|
const EMULATOR_SERIAL_PREFIX = 'emulator-';
|
|
9
9
|
const ANDROID_BOOT_POLL_MS = 1000;
|
|
10
|
+
const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
|
|
10
11
|
|
|
11
12
|
function adbArgs(serial: string, args: string[]): string[] {
|
|
12
13
|
return ['-s', serial, ...args];
|
|
@@ -16,9 +17,13 @@ function isEmulatorSerial(serial: string): boolean {
|
|
|
16
17
|
return serial.startsWith(EMULATOR_SERIAL_PREFIX);
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
async function readAndroidBootProp(
|
|
20
|
+
async function readAndroidBootProp(
|
|
21
|
+
serial: string,
|
|
22
|
+
timeoutMs = TIMEOUT_PROFILES.android_boot.operationMs,
|
|
23
|
+
): Promise<ExecResult> {
|
|
20
24
|
return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), {
|
|
21
25
|
allowFailure: true,
|
|
26
|
+
timeoutMs,
|
|
22
27
|
});
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -27,6 +32,7 @@ async function resolveAndroidDeviceName(serial: string, rawModel: string): Promi
|
|
|
27
32
|
if (!isEmulatorSerial(serial)) return modelName || serial;
|
|
28
33
|
const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
|
|
29
34
|
allowFailure: true,
|
|
35
|
+
timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
|
|
30
36
|
});
|
|
31
37
|
const avdName = avd.stdout.trim();
|
|
32
38
|
if (avd.exitCode === 0 && avdName) {
|
|
@@ -41,7 +47,9 @@ export async function listAndroidDevices(): Promise<DeviceInfo[]> {
|
|
|
41
47
|
throw new AppError('TOOL_MISSING', 'adb not found in PATH');
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
const result = await runCmd('adb', ['devices', '-l']
|
|
50
|
+
const result = await runCmd('adb', ['devices', '-l'], {
|
|
51
|
+
timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
|
|
52
|
+
});
|
|
45
53
|
const lines = result.stdout.split('\n').map((l: string) => l.trim());
|
|
46
54
|
const entries = lines
|
|
47
55
|
.filter((line) => line.length > 0 && !line.startsWith('List of devices'))
|
|
@@ -79,8 +87,9 @@ export async function isAndroidBooted(serial: string): Promise<boolean> {
|
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
90
|
+
const timeoutBudget = timeoutMs;
|
|
91
|
+
const deadline = Deadline.fromTimeoutMs(timeoutBudget);
|
|
92
|
+
const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / ANDROID_BOOT_POLL_MS));
|
|
84
93
|
let lastBootResult: ExecResult | undefined;
|
|
85
94
|
let timedOut = false;
|
|
86
95
|
try {
|
|
@@ -95,7 +104,11 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
|
|
|
95
104
|
message: 'timeout',
|
|
96
105
|
});
|
|
97
106
|
}
|
|
98
|
-
const
|
|
107
|
+
const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? timeoutBudget);
|
|
108
|
+
const result = await readAndroidBootProp(
|
|
109
|
+
serial,
|
|
110
|
+
Math.min(remainingMs, TIMEOUT_PROFILES.android_boot.operationMs),
|
|
111
|
+
);
|
|
99
112
|
lastBootResult = result;
|
|
100
113
|
if (result.stdout.trim() === '1') return;
|
|
101
114
|
throw new AppError('COMMAND_FAILED', 'Android device is still booting', {
|
|
@@ -115,41 +128,61 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
|
|
|
115
128
|
error,
|
|
116
129
|
stdout: lastBootResult?.stdout,
|
|
117
130
|
stderr: lastBootResult?.stderr,
|
|
131
|
+
context: { platform: 'android', phase: 'boot' },
|
|
118
132
|
});
|
|
119
|
-
return reason !== '
|
|
133
|
+
return reason !== 'ADB_TRANSPORT_UNAVAILABLE' && reason !== 'ANDROID_BOOT_TIMEOUT';
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
deadline,
|
|
138
|
+
phase: 'boot',
|
|
139
|
+
classifyReason: (error) =>
|
|
140
|
+
classifyBootFailure({
|
|
141
|
+
error,
|
|
142
|
+
stdout: lastBootResult?.stdout,
|
|
143
|
+
stderr: lastBootResult?.stderr,
|
|
144
|
+
context: { platform: 'android', phase: 'boot' },
|
|
145
|
+
}),
|
|
146
|
+
onEvent: (event: RetryTelemetryEvent) => {
|
|
147
|
+
if (!RETRY_LOGS_ENABLED) return;
|
|
148
|
+
process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
|
|
120
149
|
},
|
|
121
150
|
},
|
|
122
|
-
{ deadline },
|
|
123
151
|
);
|
|
124
152
|
} catch (error) {
|
|
125
153
|
const appErr = asAppError(error);
|
|
126
154
|
const stdout = lastBootResult?.stdout;
|
|
127
155
|
const stderr = lastBootResult?.stderr;
|
|
128
156
|
const exitCode = lastBootResult?.exitCode;
|
|
129
|
-
|
|
157
|
+
let reason = classifyBootFailure({
|
|
130
158
|
error,
|
|
131
159
|
stdout,
|
|
132
160
|
stderr,
|
|
161
|
+
context: { platform: 'android', phase: 'boot' },
|
|
133
162
|
});
|
|
163
|
+
if (reason === 'BOOT_COMMAND_FAILED' && appErr.message === 'Android device is still booting') {
|
|
164
|
+
reason = 'ANDROID_BOOT_TIMEOUT';
|
|
165
|
+
}
|
|
134
166
|
const baseDetails = {
|
|
135
167
|
serial,
|
|
136
|
-
timeoutMs,
|
|
168
|
+
timeoutMs: timeoutBudget,
|
|
137
169
|
elapsedMs: deadline.elapsedMs(),
|
|
138
170
|
reason,
|
|
171
|
+
hint: bootFailureHint(reason),
|
|
139
172
|
stdout,
|
|
140
173
|
stderr,
|
|
141
174
|
exitCode,
|
|
142
175
|
};
|
|
143
|
-
if (timedOut || reason === '
|
|
176
|
+
if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') {
|
|
144
177
|
throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
|
|
145
178
|
}
|
|
146
|
-
if (appErr.code === 'TOOL_MISSING'
|
|
179
|
+
if (appErr.code === 'TOOL_MISSING') {
|
|
147
180
|
throw new AppError('TOOL_MISSING', appErr.message, {
|
|
148
181
|
...baseDetails,
|
|
149
182
|
...(appErr.details ?? {}),
|
|
150
183
|
});
|
|
151
184
|
}
|
|
152
|
-
if (reason === '
|
|
185
|
+
if (reason === 'ADB_TRANSPORT_UNAVAILABLE') {
|
|
153
186
|
throw new AppError('COMMAND_FAILED', appErr.message, {
|
|
154
187
|
...baseDetails,
|
|
155
188
|
...(appErr.details ?? {}),
|
|
@@ -187,22 +187,70 @@ export async function openAndroidApp(
|
|
|
187
187
|
);
|
|
188
188
|
return;
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
try {
|
|
191
|
+
await runCmd(
|
|
192
|
+
'adb',
|
|
193
|
+
adbArgs(device, [
|
|
194
|
+
'shell',
|
|
195
|
+
'am',
|
|
196
|
+
'start',
|
|
197
|
+
'-a',
|
|
198
|
+
'android.intent.action.MAIN',
|
|
199
|
+
'-c',
|
|
200
|
+
'android.intent.category.DEFAULT',
|
|
201
|
+
'-c',
|
|
202
|
+
'android.intent.category.LAUNCHER',
|
|
203
|
+
'-p',
|
|
204
|
+
resolved.value,
|
|
205
|
+
]),
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
} catch (initialError) {
|
|
209
|
+
const component = await resolveAndroidLaunchComponent(device, resolved.value);
|
|
210
|
+
if (!component) throw initialError;
|
|
211
|
+
await runCmd(
|
|
212
|
+
'adb',
|
|
213
|
+
adbArgs(device, [
|
|
214
|
+
'shell',
|
|
215
|
+
'am',
|
|
216
|
+
'start',
|
|
217
|
+
'-a',
|
|
218
|
+
'android.intent.action.MAIN',
|
|
219
|
+
'-c',
|
|
220
|
+
'android.intent.category.DEFAULT',
|
|
221
|
+
'-c',
|
|
222
|
+
'android.intent.category.LAUNCHER',
|
|
223
|
+
'-n',
|
|
224
|
+
component,
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function resolveAndroidLaunchComponent(
|
|
231
|
+
device: DeviceInfo,
|
|
232
|
+
packageName: string,
|
|
233
|
+
): Promise<string | null> {
|
|
234
|
+
const result = await runCmd(
|
|
191
235
|
'adb',
|
|
192
|
-
adbArgs(device, [
|
|
193
|
-
|
|
194
|
-
'am',
|
|
195
|
-
'start',
|
|
196
|
-
'-a',
|
|
197
|
-
'android.intent.action.MAIN',
|
|
198
|
-
'-c',
|
|
199
|
-
'android.intent.category.DEFAULT',
|
|
200
|
-
'-c',
|
|
201
|
-
'android.intent.category.LAUNCHER',
|
|
202
|
-
'-p',
|
|
203
|
-
resolved.value,
|
|
204
|
-
]),
|
|
236
|
+
adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
|
|
237
|
+
{ allowFailure: true },
|
|
205
238
|
);
|
|
239
|
+
if (result.exitCode !== 0) return null;
|
|
240
|
+
return parseAndroidLaunchComponent(result.stdout);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function parseAndroidLaunchComponent(stdout: string): string | null {
|
|
244
|
+
const lines = stdout
|
|
245
|
+
.split('\n')
|
|
246
|
+
.map((line: string) => line.trim())
|
|
247
|
+
.filter(Boolean);
|
|
248
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
249
|
+
const line = lines[index];
|
|
250
|
+
if (!line.includes('/')) continue;
|
|
251
|
+
return line.split(/\s+/)[0];
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
export async function openAndroidDevice(device: DeviceInfo): Promise<void> {
|
|
@@ -224,6 +272,45 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
|
|
|
224
272
|
await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value]));
|
|
225
273
|
}
|
|
226
274
|
|
|
275
|
+
export async function uninstallAndroidApp(
|
|
276
|
+
device: DeviceInfo,
|
|
277
|
+
app: string,
|
|
278
|
+
): Promise<{ package: string }> {
|
|
279
|
+
const resolved = await resolveAndroidApp(device, app);
|
|
280
|
+
if (resolved.type === 'intent') {
|
|
281
|
+
throw new AppError('INVALID_ARGS', 'reinstall requires a package name, not an intent');
|
|
282
|
+
}
|
|
283
|
+
const result = await runCmd('adb', adbArgs(device, ['uninstall', resolved.value]), { allowFailure: true });
|
|
284
|
+
if (result.exitCode !== 0) {
|
|
285
|
+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
286
|
+
if (!output.includes('unknown package') && !output.includes('not installed')) {
|
|
287
|
+
throw new AppError('COMMAND_FAILED', `adb uninstall failed for ${resolved.value}`, {
|
|
288
|
+
stdout: result.stdout,
|
|
289
|
+
stderr: result.stderr,
|
|
290
|
+
exitCode: result.exitCode,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { package: resolved.value };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function installAndroidApp(device: DeviceInfo, appPath: string): Promise<void> {
|
|
298
|
+
await runCmd('adb', adbArgs(device, ['install', appPath]));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function reinstallAndroidApp(
|
|
302
|
+
device: DeviceInfo,
|
|
303
|
+
app: string,
|
|
304
|
+
appPath: string,
|
|
305
|
+
): Promise<{ package: string }> {
|
|
306
|
+
if (!device.booted) {
|
|
307
|
+
await waitForAndroidBoot(device.id);
|
|
308
|
+
}
|
|
309
|
+
const { package: pkg } = await uninstallAndroidApp(device, app);
|
|
310
|
+
await installAndroidApp(device, appPath);
|
|
311
|
+
return { package: pkg };
|
|
312
|
+
}
|
|
313
|
+
|
|
227
314
|
export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
|
|
228
315
|
await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
|
|
229
316
|
}
|
|
@@ -1,22 +1,33 @@
|
|
|
1
1
|
import { asAppError } from '../utils/errors.ts';
|
|
2
2
|
|
|
3
3
|
export type BootFailureReason =
|
|
4
|
-
| '
|
|
5
|
-
| '
|
|
6
|
-
| '
|
|
7
|
-
| '
|
|
8
|
-
| '
|
|
4
|
+
| 'IOS_BOOT_TIMEOUT'
|
|
5
|
+
| 'IOS_RUNNER_CONNECT_TIMEOUT'
|
|
6
|
+
| 'IOS_TOOL_MISSING'
|
|
7
|
+
| 'ANDROID_BOOT_TIMEOUT'
|
|
8
|
+
| 'ADB_TRANSPORT_UNAVAILABLE'
|
|
9
|
+
| 'CI_RESOURCE_STARVATION_SUSPECTED'
|
|
9
10
|
| 'BOOT_COMMAND_FAILED'
|
|
10
11
|
| 'UNKNOWN';
|
|
11
12
|
|
|
13
|
+
type BootDiagnosticContext = {
|
|
14
|
+
platform?: 'ios' | 'android';
|
|
15
|
+
phase?: 'boot' | 'connect' | 'transport';
|
|
16
|
+
};
|
|
17
|
+
|
|
12
18
|
export function classifyBootFailure(input: {
|
|
13
19
|
error?: unknown;
|
|
14
20
|
message?: string;
|
|
15
21
|
stdout?: string;
|
|
16
22
|
stderr?: string;
|
|
23
|
+
context?: BootDiagnosticContext;
|
|
17
24
|
}): BootFailureReason {
|
|
18
25
|
const appErr = input.error ? asAppError(input.error) : null;
|
|
19
|
-
|
|
26
|
+
const platform = input.context?.platform;
|
|
27
|
+
const phase = input.context?.phase;
|
|
28
|
+
if (appErr?.code === 'TOOL_MISSING') {
|
|
29
|
+
return platform === 'android' ? 'ADB_TRANSPORT_UNAVAILABLE' : 'IOS_TOOL_MISSING';
|
|
30
|
+
}
|
|
20
31
|
const details = (appErr?.details ?? {}) as Record<string, unknown>;
|
|
21
32
|
const detailMessage = typeof details.message === 'string' ? details.message : undefined;
|
|
22
33
|
const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined;
|
|
@@ -45,23 +56,73 @@ export function classifyBootFailure(input: {
|
|
|
45
56
|
.join('\n')
|
|
46
57
|
.toLowerCase();
|
|
47
58
|
|
|
48
|
-
if (haystack.includes('timed out') || haystack.includes('timeout')) return 'BOOT_TIMEOUT';
|
|
49
59
|
if (
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
platform === 'ios' &&
|
|
61
|
+
(
|
|
62
|
+
haystack.includes('runner did not accept connection') ||
|
|
63
|
+
(phase === 'connect' &&
|
|
64
|
+
(
|
|
65
|
+
haystack.includes('timed out') ||
|
|
66
|
+
haystack.includes('timeout') ||
|
|
67
|
+
haystack.includes('econnrefused') ||
|
|
68
|
+
haystack.includes('connection refused') ||
|
|
69
|
+
haystack.includes('fetch failed') ||
|
|
70
|
+
haystack.includes('socket hang up')
|
|
71
|
+
))
|
|
72
|
+
)
|
|
73
|
+
) {
|
|
74
|
+
return 'IOS_RUNNER_CONNECT_TIMEOUT';
|
|
75
|
+
}
|
|
76
|
+
if (platform === 'ios' && phase === 'boot' && (haystack.includes('timed out') || haystack.includes('timeout'))) {
|
|
77
|
+
return 'IOS_BOOT_TIMEOUT';
|
|
78
|
+
}
|
|
79
|
+
if (platform === 'android' && phase === 'boot' && (haystack.includes('timed out') || haystack.includes('timeout'))) {
|
|
80
|
+
return 'ANDROID_BOOT_TIMEOUT';
|
|
81
|
+
}
|
|
82
|
+
if (
|
|
83
|
+
haystack.includes('resource temporarily unavailable') ||
|
|
84
|
+
haystack.includes('killed: 9') ||
|
|
85
|
+
haystack.includes('cannot allocate memory') ||
|
|
86
|
+
haystack.includes('system is low on memory')
|
|
54
87
|
) {
|
|
55
|
-
return '
|
|
88
|
+
return 'CI_RESOURCE_STARVATION_SUSPECTED';
|
|
56
89
|
}
|
|
57
|
-
if (haystack.includes('offline')) return 'DEVICE_OFFLINE';
|
|
58
90
|
if (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
91
|
+
platform === 'android' &&
|
|
92
|
+
(
|
|
93
|
+
haystack.includes('device not found') ||
|
|
94
|
+
haystack.includes('no devices') ||
|
|
95
|
+
haystack.includes('device offline') ||
|
|
96
|
+
haystack.includes('offline') ||
|
|
97
|
+
haystack.includes('unauthorized') ||
|
|
98
|
+
haystack.includes('not authorized') ||
|
|
99
|
+
haystack.includes('unable to locate device') ||
|
|
100
|
+
haystack.includes('invalid device')
|
|
101
|
+
)
|
|
62
102
|
) {
|
|
63
|
-
return '
|
|
103
|
+
return 'ADB_TRANSPORT_UNAVAILABLE';
|
|
64
104
|
}
|
|
65
105
|
if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED';
|
|
66
106
|
return 'UNKNOWN';
|
|
67
107
|
}
|
|
108
|
+
|
|
109
|
+
export function bootFailureHint(reason: BootFailureReason): string {
|
|
110
|
+
switch (reason) {
|
|
111
|
+
case 'IOS_BOOT_TIMEOUT':
|
|
112
|
+
return 'Retry simulator boot and inspect simctl bootstatus logs; in CI consider increasing AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS.';
|
|
113
|
+
case 'IOS_RUNNER_CONNECT_TIMEOUT':
|
|
114
|
+
return 'Retry runner startup, inspect xcodebuild logs, and verify simulator responsiveness before command execution.';
|
|
115
|
+
case 'ANDROID_BOOT_TIMEOUT':
|
|
116
|
+
return 'Retry emulator startup and verify sys.boot_completed reaches 1; consider increasing startup budget in CI.';
|
|
117
|
+
case 'ADB_TRANSPORT_UNAVAILABLE':
|
|
118
|
+
return 'Check adb server/device transport (adb devices -l), restart adb, and ensure the target device is online and authorized.';
|
|
119
|
+
case 'CI_RESOURCE_STARVATION_SUSPECTED':
|
|
120
|
+
return 'CI machine may be resource constrained; reduce parallel jobs or use a larger runner.';
|
|
121
|
+
case 'IOS_TOOL_MISSING':
|
|
122
|
+
return 'Xcode command-line tools are missing or not in PATH; run xcode-select --install and verify xcrun works.';
|
|
123
|
+
case 'BOOT_COMMAND_FAILED':
|
|
124
|
+
return 'Inspect command stderr/stdout for the failing boot phase and retry after environment validation.';
|
|
125
|
+
default:
|
|
126
|
+
return 'Retry once and inspect verbose logs for the failing phase.';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -2,14 +2,19 @@ import { runCmd } from '../../utils/exec.ts';
|
|
|
2
2
|
import type { ExecResult } from '../../utils/exec.ts';
|
|
3
3
|
import { AppError } from '../../utils/errors.ts';
|
|
4
4
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
5
|
-
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
|
|
6
|
-
import { classifyBootFailure } from '../boot-diagnostics.ts';
|
|
5
|
+
import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
|
|
6
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
7
7
|
|
|
8
8
|
const ALIASES: Record<string, string> = {
|
|
9
9
|
settings: 'com.apple.Preferences',
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(
|
|
12
|
+
const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(
|
|
13
|
+
process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS,
|
|
14
|
+
TIMEOUT_PROFILES.ios_boot.totalMs,
|
|
15
|
+
5_000,
|
|
16
|
+
);
|
|
17
|
+
const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
|
|
13
18
|
|
|
14
19
|
export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
|
|
15
20
|
const trimmed = app.trim();
|
|
@@ -88,6 +93,42 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise<void
|
|
|
88
93
|
]);
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
|
|
97
|
+
ensureSimulator(device, 'reinstall');
|
|
98
|
+
const bundleId = await resolveIosApp(device, app);
|
|
99
|
+
await ensureBootedSimulator(device);
|
|
100
|
+
const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], {
|
|
101
|
+
allowFailure: true,
|
|
102
|
+
});
|
|
103
|
+
if (result.exitCode !== 0) {
|
|
104
|
+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
105
|
+
if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) {
|
|
106
|
+
throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
|
|
107
|
+
stdout: result.stdout,
|
|
108
|
+
stderr: result.stderr,
|
|
109
|
+
exitCode: result.exitCode,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { bundleId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
|
|
117
|
+
ensureSimulator(device, 'reinstall');
|
|
118
|
+
await ensureBootedSimulator(device);
|
|
119
|
+
await runCmd('xcrun', ['simctl', 'install', device.id, appPath]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function reinstallIosApp(
|
|
123
|
+
device: DeviceInfo,
|
|
124
|
+
app: string,
|
|
125
|
+
appPath: string,
|
|
126
|
+
): Promise<{ bundleId: string }> {
|
|
127
|
+
const { bundleId } = await uninstallIosApp(device, app);
|
|
128
|
+
await installIosApp(device, appPath);
|
|
129
|
+
return { bundleId };
|
|
130
|
+
}
|
|
131
|
+
|
|
91
132
|
export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
|
|
92
133
|
if (device.kind === 'simulator') {
|
|
93
134
|
await ensureBootedSimulator(device);
|
|
@@ -217,10 +258,17 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
217
258
|
let bootStatusResult: ExecResult | undefined;
|
|
218
259
|
try {
|
|
219
260
|
await retryWithPolicy(
|
|
220
|
-
async () => {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
261
|
+
async ({ deadline: attemptDeadline }) => {
|
|
262
|
+
if (attemptDeadline?.isExpired()) {
|
|
263
|
+
throw new AppError('COMMAND_FAILED', 'iOS simulator boot deadline exceeded', {
|
|
264
|
+
timeoutMs: IOS_BOOT_TIMEOUT_MS,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? IOS_BOOT_TIMEOUT_MS);
|
|
268
|
+
bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], {
|
|
269
|
+
allowFailure: true,
|
|
270
|
+
timeoutMs: remainingMs,
|
|
271
|
+
});
|
|
224
272
|
const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
|
|
225
273
|
const bootAlreadyDone =
|
|
226
274
|
bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
|
|
@@ -233,6 +281,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
233
281
|
}
|
|
234
282
|
bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
|
|
235
283
|
allowFailure: true,
|
|
284
|
+
timeoutMs: remainingMs,
|
|
236
285
|
});
|
|
237
286
|
if (bootStatusResult.exitCode !== 0) {
|
|
238
287
|
throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
|
|
@@ -258,11 +307,26 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
258
307
|
error,
|
|
259
308
|
stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
|
|
260
309
|
stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
|
|
310
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
261
311
|
});
|
|
262
|
-
return reason !== '
|
|
312
|
+
return reason !== 'IOS_BOOT_TIMEOUT' && reason !== 'CI_RESOURCE_STARVATION_SUSPECTED';
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
deadline,
|
|
317
|
+
phase: 'boot',
|
|
318
|
+
classifyReason: (error) =>
|
|
319
|
+
classifyBootFailure({
|
|
320
|
+
error,
|
|
321
|
+
stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
|
|
322
|
+
stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
|
|
323
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
324
|
+
}),
|
|
325
|
+
onEvent: (event: RetryTelemetryEvent) => {
|
|
326
|
+
if (!RETRY_LOGS_ENABLED) return;
|
|
327
|
+
process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
|
|
263
328
|
},
|
|
264
329
|
},
|
|
265
|
-
{ deadline },
|
|
266
330
|
);
|
|
267
331
|
} catch (error) {
|
|
268
332
|
const bootStdout = bootResult?.stdout;
|
|
@@ -275,6 +339,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
275
339
|
error,
|
|
276
340
|
stdout: bootstatusStdout ?? bootStdout,
|
|
277
341
|
stderr: bootstatusStderr ?? bootStderr,
|
|
342
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
278
343
|
});
|
|
279
344
|
throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
|
|
280
345
|
platform: 'ios',
|
|
@@ -282,6 +347,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
282
347
|
timeoutMs: IOS_BOOT_TIMEOUT_MS,
|
|
283
348
|
elapsedMs: deadline.elapsedMs(),
|
|
284
349
|
reason,
|
|
350
|
+
hint: bootFailureHint(reason),
|
|
285
351
|
boot: bootResult
|
|
286
352
|
? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
|
|
287
353
|
: undefined,
|
|
@@ -299,6 +365,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
299
365
|
async function getSimulatorState(udid: string): Promise<string | null> {
|
|
300
366
|
const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
|
|
301
367
|
allowFailure: true,
|
|
368
|
+
timeoutMs: TIMEOUT_PROFILES.ios_boot.operationMs,
|
|
302
369
|
});
|
|
303
370
|
if (result.exitCode !== 0) return null;
|
|
304
371
|
try {
|
|
@@ -7,6 +7,7 @@ import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBa
|
|
|
7
7
|
import { withRetry } from '../../utils/retry.ts';
|
|
8
8
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
10
11
|
|
|
11
12
|
export type RunnerCommand = {
|
|
12
13
|
command:
|
|
@@ -204,7 +205,10 @@ export async function stopIosRunnerSession(deviceId: string): Promise<void> {
|
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
async function ensureBooted(udid: string): Promise<void> {
|
|
207
|
-
await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], {
|
|
208
|
+
await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], {
|
|
209
|
+
allowFailure: true,
|
|
210
|
+
timeoutMs: RUNNER_STARTUP_TIMEOUT_MS,
|
|
211
|
+
});
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
async function ensureRunnerSession(
|
|
@@ -449,6 +453,12 @@ async function waitForRunner(
|
|
|
449
453
|
port,
|
|
450
454
|
logPath,
|
|
451
455
|
lastError: lastError ? String(lastError) : undefined,
|
|
456
|
+
reason: classifyBootFailure({
|
|
457
|
+
error: lastError,
|
|
458
|
+
message: 'Runner did not accept connection',
|
|
459
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
460
|
+
}),
|
|
461
|
+
hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
|
|
452
462
|
});
|
|
453
463
|
}
|
|
454
464
|
|
|
@@ -478,11 +488,19 @@ async function postCommandViaSimulator(
|
|
|
478
488
|
);
|
|
479
489
|
const body = result.stdout as string;
|
|
480
490
|
if (result.exitCode !== 0) {
|
|
491
|
+
const reason = classifyBootFailure({
|
|
492
|
+
message: 'Runner did not accept connection (simctl spawn)',
|
|
493
|
+
stdout: result.stdout,
|
|
494
|
+
stderr: result.stderr,
|
|
495
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
496
|
+
});
|
|
481
497
|
throw new AppError('COMMAND_FAILED', 'Runner did not accept connection (simctl spawn)', {
|
|
482
498
|
port,
|
|
483
499
|
stdout: result.stdout,
|
|
484
500
|
stderr: result.stderr,
|
|
485
501
|
exitCode: result.exitCode,
|
|
502
|
+
reason,
|
|
503
|
+
hint: bootFailureHint(reason),
|
|
486
504
|
});
|
|
487
505
|
}
|
|
488
506
|
return { status: 200, body };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runCmd } from '../exec.ts';
|
|
4
|
+
|
|
5
|
+
test('runCmd enforces timeoutMs and rejects with COMMAND_FAILED', async () => {
|
|
6
|
+
await assert.rejects(
|
|
7
|
+
runCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], { timeoutMs: 100 }),
|
|
8
|
+
(error: unknown) => {
|
|
9
|
+
const err = error as { code?: string; message?: string; details?: Record<string, unknown> };
|
|
10
|
+
return err?.code === 'COMMAND_FAILED' &&
|
|
11
|
+
typeof err?.message === 'string' &&
|
|
12
|
+
err.message.includes('timed out') &&
|
|
13
|
+
err.details?.timeoutMs === 100;
|
|
14
|
+
},
|
|
15
|
+
);
|
|
16
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { findBestMatchesByLocator, findNodeByLocator } from '../finders.ts';
|
|
4
|
+
import type { SnapshotNode } from '../snapshot.ts';
|
|
5
|
+
|
|
6
|
+
function makeNode(ref: string, label?: string, identifier?: string): SnapshotNode {
|
|
7
|
+
return {
|
|
8
|
+
index: Number(ref.replace('e', '')) || 0,
|
|
9
|
+
ref,
|
|
10
|
+
type: 'android.widget.TextView',
|
|
11
|
+
label,
|
|
12
|
+
identifier,
|
|
13
|
+
rect: { x: 0, y: 0, width: 100, height: 20 },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('findBestMatchesByLocator returns all best-scored matches', () => {
|
|
18
|
+
const nodes: SnapshotNode[] = [
|
|
19
|
+
makeNode('e1', 'Continue'),
|
|
20
|
+
makeNode('e2', 'Continue'),
|
|
21
|
+
makeNode('e3', 'Continue later'),
|
|
22
|
+
];
|
|
23
|
+
const result = findBestMatchesByLocator(nodes, 'label', 'Continue', { requireRect: true });
|
|
24
|
+
assert.equal(result.score, 2);
|
|
25
|
+
assert.equal(result.matches.length, 2);
|
|
26
|
+
assert.equal(result.matches[0]?.ref, 'e1');
|
|
27
|
+
assert.equal(result.matches[1]?.ref, 'e2');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('findNodeByLocator preserves first best match behavior', () => {
|
|
31
|
+
const nodes: SnapshotNode[] = [makeNode('e1', 'Continue'), makeNode('e2', 'Continue')];
|
|
32
|
+
const match = findNodeByLocator(nodes, 'label', 'Continue', { requireRect: true });
|
|
33
|
+
assert.equal(match?.ref, 'e1');
|
|
34
|
+
});
|
|
@@ -25,3 +25,20 @@ test('retryWithPolicy retries until success', async () => {
|
|
|
25
25
|
assert.equal(result, 'ok');
|
|
26
26
|
assert.equal(attempts, 3);
|
|
27
27
|
});
|
|
28
|
+
|
|
29
|
+
test('retryWithPolicy emits telemetry events', async () => {
|
|
30
|
+
const events: string[] = [];
|
|
31
|
+
await retryWithPolicy(
|
|
32
|
+
async ({ attempt }) => {
|
|
33
|
+
if (attempt === 1) throw new Error('transient');
|
|
34
|
+
return 'ok';
|
|
35
|
+
},
|
|
36
|
+
{ maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 },
|
|
37
|
+
{
|
|
38
|
+
phase: 'boot',
|
|
39
|
+
classifyReason: () => 'ANDROID_BOOT_TIMEOUT',
|
|
40
|
+
onEvent: (event) => events.push(event.event),
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
assert.deepEqual(events, ['attempt_failed', 'retry_scheduled', 'succeeded']);
|
|
44
|
+
});
|