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.
@@ -1,22 +1,33 @@
1
1
  import { asAppError } from '../utils/errors.ts';
2
2
 
3
3
  export type BootFailureReason =
4
- | 'BOOT_TIMEOUT'
5
- | 'DEVICE_UNAVAILABLE'
6
- | 'DEVICE_OFFLINE'
7
- | 'PERMISSION_DENIED'
8
- | 'TOOL_MISSING'
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
- if (appErr?.code === 'TOOL_MISSING') return 'TOOL_MISSING';
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
- haystack.includes('device not found') ||
51
- haystack.includes('no devices') ||
52
- haystack.includes('unable to locate device') ||
53
- haystack.includes('invalid device')
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 'DEVICE_UNAVAILABLE';
88
+ return 'CI_RESOURCE_STARVATION_SUSPECTED';
56
89
  }
57
- if (haystack.includes('offline')) return 'DEVICE_OFFLINE';
58
90
  if (
59
- haystack.includes('permission denied') ||
60
- haystack.includes('not authorized') ||
61
- haystack.includes('unauthorized')
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 'PERMISSION_DENIED';
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(process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS, 120_000, 5_000);
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
- const currentState = await getSimulatorState(device.id);
222
- if (currentState === 'Booted') return;
223
- bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true });
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 !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING';
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'], { allowFailure: true });
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
+ }
@@ -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 null;
17
- let best: { node: SnapshotNode; score: number } | null = null;
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 (!best || score > best.score) {
23
- best = { node, score };
24
- if (score >= 2) {
25
- // exact match, keep first exact match
26
- break;
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 best?.node ?? null;
48
+ return { matches, score: bestScore };
31
49
  }
32
50
 
33
51
  function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {
@@ -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: { deadline?: Deadline } = {},
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
- return await fn({ attempt, maxAttempts: merged.maxAttempts, deadline: options.deadline });
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
  }