agent-device 0.3.1 → 0.3.2
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/handlers/__tests__/replay-heal.test.ts +5 -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/session.ts +175 -10
- 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
|
@@ -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
|
+
});
|
package/src/utils/args.ts
CHANGED
|
@@ -173,8 +173,10 @@ export function usage(): string {
|
|
|
173
173
|
CLI to control iOS and Android devices for AI agents.
|
|
174
174
|
|
|
175
175
|
Commands:
|
|
176
|
+
boot Ensure target device/simulator is booted and ready
|
|
176
177
|
open [app] Boot device/simulator; optionally launch app
|
|
177
178
|
close [app] Close app or just end session
|
|
179
|
+
reinstall <app> <path> Uninstall + install app from binary path
|
|
178
180
|
snapshot [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--backend ax|xctest]
|
|
179
181
|
Capture accessibility tree
|
|
180
182
|
-i Interactive elements only
|
package/src/utils/exec.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type ExecOptions = {
|
|
|
14
14
|
allowFailure?: boolean;
|
|
15
15
|
binaryStdout?: boolean;
|
|
16
16
|
stdin?: string | Buffer;
|
|
17
|
+
timeoutMs?: number;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export type ExecStreamOptions = ExecOptions & {
|
|
@@ -41,6 +42,14 @@ export async function runCmd(
|
|
|
41
42
|
let stdout = '';
|
|
42
43
|
let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined;
|
|
43
44
|
let stderr = '';
|
|
45
|
+
let didTimeout = false;
|
|
46
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
47
|
+
const timeoutHandle = timeoutMs
|
|
48
|
+
? setTimeout(() => {
|
|
49
|
+
didTimeout = true;
|
|
50
|
+
child.kill('SIGKILL');
|
|
51
|
+
}, timeoutMs)
|
|
52
|
+
: null;
|
|
44
53
|
|
|
45
54
|
if (!options.binaryStdout) child.stdout.setEncoding('utf8');
|
|
46
55
|
child.stderr.setEncoding('utf8');
|
|
@@ -66,6 +75,7 @@ export async function runCmd(
|
|
|
66
75
|
});
|
|
67
76
|
|
|
68
77
|
child.on('error', (err) => {
|
|
78
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
69
79
|
const code = (err as NodeJS.ErrnoException).code;
|
|
70
80
|
if (code === 'ENOENT') {
|
|
71
81
|
reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err));
|
|
@@ -75,7 +85,21 @@ export async function runCmd(
|
|
|
75
85
|
});
|
|
76
86
|
|
|
77
87
|
child.on('close', (code) => {
|
|
88
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
78
89
|
const exitCode = code ?? 1;
|
|
90
|
+
if (didTimeout && timeoutMs) {
|
|
91
|
+
reject(
|
|
92
|
+
new AppError('COMMAND_FAILED', `${cmd} timed out after ${timeoutMs}ms`, {
|
|
93
|
+
cmd,
|
|
94
|
+
args,
|
|
95
|
+
stdout,
|
|
96
|
+
stderr,
|
|
97
|
+
exitCode,
|
|
98
|
+
timeoutMs,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
79
103
|
if (exitCode !== 0 && !options.allowFailure) {
|
|
80
104
|
reject(
|
|
81
105
|
new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, {
|
|
@@ -110,10 +134,18 @@ export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {
|
|
|
110
134
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
111
135
|
encoding: options.binaryStdout ? undefined : 'utf8',
|
|
112
136
|
input: options.stdin,
|
|
137
|
+
timeout: normalizeTimeoutMs(options.timeoutMs),
|
|
113
138
|
});
|
|
114
139
|
|
|
115
140
|
if (result.error) {
|
|
116
141
|
const code = (result.error as NodeJS.ErrnoException).code;
|
|
142
|
+
if (code === 'ETIMEDOUT') {
|
|
143
|
+
throw new AppError('COMMAND_FAILED', `${cmd} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`, {
|
|
144
|
+
cmd,
|
|
145
|
+
args,
|
|
146
|
+
timeoutMs: normalizeTimeoutMs(options.timeoutMs),
|
|
147
|
+
}, result.error);
|
|
148
|
+
}
|
|
117
149
|
if (code === 'ENOENT') {
|
|
118
150
|
throw new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, result.error);
|
|
119
151
|
}
|
|
@@ -298,3 +330,10 @@ function resolveWhichArgs(cmd: string): { shell: string; args: string[] } {
|
|
|
298
330
|
}
|
|
299
331
|
return { shell: 'bash', args: ['-lc', `command -v ${cmd}`] };
|
|
300
332
|
}
|
|
333
|
+
|
|
334
|
+
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
335
|
+
if (!Number.isFinite(value)) return undefined;
|
|
336
|
+
const timeout = Math.floor(value as number);
|
|
337
|
+
if (timeout <= 0) return undefined;
|
|
338
|
+
return timeout;
|
|
339
|
+
}
|
package/src/utils/finders.ts
CHANGED
|
@@ -6,28 +6,46 @@ export type FindMatchOptions = {
|
|
|
6
6
|
requireRect?: boolean;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
export type FindBestMatches = {
|
|
10
|
+
matches: SnapshotNode[];
|
|
11
|
+
score: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
9
14
|
export function findNodeByLocator(
|
|
10
15
|
nodes: SnapshotNode[],
|
|
11
16
|
locator: FindLocator,
|
|
12
17
|
query: string,
|
|
13
18
|
options: FindMatchOptions = {},
|
|
14
19
|
): SnapshotNode | null {
|
|
20
|
+
const best = findBestMatchesByLocator(nodes, locator, query, options);
|
|
21
|
+
return best.matches[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findBestMatchesByLocator(
|
|
25
|
+
nodes: SnapshotNode[],
|
|
26
|
+
locator: FindLocator,
|
|
27
|
+
query: string,
|
|
28
|
+
options: FindMatchOptions = {},
|
|
29
|
+
): FindBestMatches {
|
|
15
30
|
const normalizedQuery = normalizeText(query);
|
|
16
|
-
if (!normalizedQuery) return
|
|
17
|
-
let
|
|
31
|
+
if (!normalizedQuery) return { matches: [], score: 0 };
|
|
32
|
+
let bestScore = 0;
|
|
33
|
+
const matches: SnapshotNode[] = [];
|
|
18
34
|
for (const node of nodes) {
|
|
19
35
|
if (options.requireRect && !node.rect) continue;
|
|
20
36
|
const score = matchNode(node, locator, normalizedQuery);
|
|
21
37
|
if (score <= 0) continue;
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
if (score > bestScore) {
|
|
39
|
+
bestScore = score;
|
|
40
|
+
matches.length = 0;
|
|
41
|
+
matches.push(node);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (score === bestScore) {
|
|
45
|
+
matches.push(node);
|
|
28
46
|
}
|
|
29
47
|
}
|
|
30
|
-
return
|
|
48
|
+
return { matches, score: bestScore };
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {
|
package/src/utils/retry.ts
CHANGED
|
@@ -22,6 +22,33 @@ export type RetryAttemptContext = {
|
|
|
22
22
|
deadline?: Deadline;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
export type TimeoutProfile = {
|
|
26
|
+
startupMs: number;
|
|
27
|
+
operationMs: number;
|
|
28
|
+
totalMs: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type RetryTelemetryEvent = {
|
|
32
|
+
phase?: string;
|
|
33
|
+
event: 'attempt_failed' | 'retry_scheduled' | 'succeeded' | 'exhausted';
|
|
34
|
+
attempt: number;
|
|
35
|
+
maxAttempts: number;
|
|
36
|
+
delayMs?: number;
|
|
37
|
+
elapsedMs?: number;
|
|
38
|
+
remainingMs?: number;
|
|
39
|
+
reason?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function isEnvTruthy(value: string | undefined): boolean {
|
|
43
|
+
return ['1', 'true', 'yes', 'on'].includes((value ?? '').toLowerCase());
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const TIMEOUT_PROFILES: Record<string, TimeoutProfile> = {
|
|
47
|
+
ios_boot: { startupMs: 120_000, operationMs: 20_000, totalMs: 120_000 },
|
|
48
|
+
ios_runner_connect: { startupMs: 120_000, operationMs: 15_000, totalMs: 120_000 },
|
|
49
|
+
android_boot: { startupMs: 60_000, operationMs: 10_000, totalMs: 60_000 },
|
|
50
|
+
};
|
|
51
|
+
|
|
25
52
|
const defaultOptions: Required<Pick<RetryOptions, 'attempts' | 'baseDelayMs' | 'maxDelayMs' | 'jitter'>> = {
|
|
26
53
|
attempts: 3,
|
|
27
54
|
baseDelayMs: 200,
|
|
@@ -58,7 +85,12 @@ export class Deadline {
|
|
|
58
85
|
export async function retryWithPolicy<T>(
|
|
59
86
|
fn: (context: RetryAttemptContext) => Promise<T>,
|
|
60
87
|
policy: Partial<RetryPolicy> = {},
|
|
61
|
-
options: {
|
|
88
|
+
options: {
|
|
89
|
+
deadline?: Deadline;
|
|
90
|
+
phase?: string;
|
|
91
|
+
classifyReason?: (error: unknown) => string | undefined;
|
|
92
|
+
onEvent?: (event: RetryTelemetryEvent) => void;
|
|
93
|
+
} = {},
|
|
62
94
|
): Promise<T> {
|
|
63
95
|
const merged: RetryPolicy = {
|
|
64
96
|
maxAttempts: policy.maxAttempts ?? defaultOptions.attempts,
|
|
@@ -71,17 +103,55 @@ export async function retryWithPolicy<T>(
|
|
|
71
103
|
for (let attempt = 1; attempt <= merged.maxAttempts; attempt += 1) {
|
|
72
104
|
if (options.deadline?.isExpired() && attempt > 1) break;
|
|
73
105
|
try {
|
|
74
|
-
|
|
106
|
+
const result = await fn({ attempt, maxAttempts: merged.maxAttempts, deadline: options.deadline });
|
|
107
|
+
options.onEvent?.({
|
|
108
|
+
phase: options.phase,
|
|
109
|
+
event: 'succeeded',
|
|
110
|
+
attempt,
|
|
111
|
+
maxAttempts: merged.maxAttempts,
|
|
112
|
+
elapsedMs: options.deadline?.elapsedMs(),
|
|
113
|
+
remainingMs: options.deadline?.remainingMs(),
|
|
114
|
+
});
|
|
115
|
+
return result;
|
|
75
116
|
} catch (err) {
|
|
76
117
|
lastError = err;
|
|
118
|
+
const reason = options.classifyReason?.(err);
|
|
119
|
+
options.onEvent?.({
|
|
120
|
+
phase: options.phase,
|
|
121
|
+
event: 'attempt_failed',
|
|
122
|
+
attempt,
|
|
123
|
+
maxAttempts: merged.maxAttempts,
|
|
124
|
+
elapsedMs: options.deadline?.elapsedMs(),
|
|
125
|
+
remainingMs: options.deadline?.remainingMs(),
|
|
126
|
+
reason,
|
|
127
|
+
});
|
|
77
128
|
if (attempt >= merged.maxAttempts) break;
|
|
78
129
|
if (merged.shouldRetry && !merged.shouldRetry(err, attempt)) break;
|
|
79
130
|
const delay = computeDelay(merged.baseDelayMs, merged.maxDelayMs, merged.jitter, attempt);
|
|
80
131
|
const boundedDelay = options.deadline ? Math.min(delay, options.deadline.remainingMs()) : delay;
|
|
81
132
|
if (boundedDelay <= 0) break;
|
|
133
|
+
options.onEvent?.({
|
|
134
|
+
phase: options.phase,
|
|
135
|
+
event: 'retry_scheduled',
|
|
136
|
+
attempt,
|
|
137
|
+
maxAttempts: merged.maxAttempts,
|
|
138
|
+
delayMs: boundedDelay,
|
|
139
|
+
elapsedMs: options.deadline?.elapsedMs(),
|
|
140
|
+
remainingMs: options.deadline?.remainingMs(),
|
|
141
|
+
reason,
|
|
142
|
+
});
|
|
82
143
|
await sleep(boundedDelay);
|
|
83
144
|
}
|
|
84
145
|
}
|
|
146
|
+
options.onEvent?.({
|
|
147
|
+
phase: options.phase,
|
|
148
|
+
event: 'exhausted',
|
|
149
|
+
attempt: merged.maxAttempts,
|
|
150
|
+
maxAttempts: merged.maxAttempts,
|
|
151
|
+
elapsedMs: options.deadline?.elapsedMs(),
|
|
152
|
+
remainingMs: options.deadline?.remainingMs(),
|
|
153
|
+
reason: options.classifyReason?.(lastError),
|
|
154
|
+
});
|
|
85
155
|
if (lastError) throw lastError;
|
|
86
156
|
throw new AppError('COMMAND_FAILED', 'retry failed');
|
|
87
157
|
}
|