agent-device 0.3.0 → 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.
Files changed (34) hide show
  1. package/README.md +26 -2
  2. package/dist/src/274.js +1 -0
  3. package/dist/src/bin.js +27 -22
  4. package/dist/src/daemon.js +15 -10
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
  6. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +149 -0
  7. package/package.json +2 -2
  8. package/skills/agent-device/SKILL.md +8 -1
  9. package/src/cli.ts +13 -0
  10. package/src/core/__tests__/capabilities.test.ts +2 -0
  11. package/src/core/capabilities.ts +2 -0
  12. package/src/daemon/handlers/__tests__/replay-heal.test.ts +5 -0
  13. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
  14. package/src/daemon/handlers/__tests__/session.test.ts +122 -0
  15. package/src/daemon/handlers/find.ts +23 -3
  16. package/src/daemon/handlers/session.ts +175 -10
  17. package/src/daemon-client.ts +1 -24
  18. package/src/daemon.ts +1 -24
  19. package/src/platforms/__tests__/boot-diagnostics.test.ts +59 -0
  20. package/src/platforms/android/__tests__/index.test.ts +17 -0
  21. package/src/platforms/android/devices.ts +167 -42
  22. package/src/platforms/android/index.ts +101 -14
  23. package/src/platforms/boot-diagnostics.ts +128 -0
  24. package/src/platforms/ios/index.ts +161 -2
  25. package/src/platforms/ios/runner-client.ts +19 -1
  26. package/src/utils/__tests__/exec.test.ts +16 -0
  27. package/src/utils/__tests__/finders.test.ts +34 -0
  28. package/src/utils/__tests__/retry.test.ts +44 -0
  29. package/src/utils/args.ts +9 -1
  30. package/src/utils/exec.ts +39 -0
  31. package/src/utils/finders.ts +27 -9
  32. package/src/utils/retry.ts +143 -13
  33. package/src/utils/version.ts +26 -0
  34. package/dist/src/861.js +0 -1
@@ -1,11 +1,21 @@
1
1
  import { runCmd } from '../../utils/exec.ts';
2
+ import type { ExecResult } from '../../utils/exec.ts';
2
3
  import { AppError } from '../../utils/errors.ts';
3
4
  import type { DeviceInfo } from '../../utils/device.ts';
5
+ import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
6
+ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
4
7
 
5
8
  const ALIASES: Record<string, string> = {
6
9
  settings: 'com.apple.Preferences',
7
10
  };
8
11
 
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);
18
+
9
19
  export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
10
20
  const trimmed = app.trim();
11
21
  if (trimmed.includes('.')) return trimmed;
@@ -83,6 +93,42 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise<void
83
93
  ]);
84
94
  }
85
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
+
86
132
  export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
87
133
  if (device.kind === 'simulator') {
88
134
  await ensureBootedSimulator(device);
@@ -207,13 +253,119 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
207
253
  if (device.kind !== 'simulator') return;
208
254
  const state = await getSimulatorState(device.id);
209
255
  if (state === 'Booted') return;
210
- await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true });
211
- await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], { allowFailure: true });
256
+ const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
257
+ let bootResult: ExecResult | undefined;
258
+ let bootStatusResult: ExecResult | undefined;
259
+ try {
260
+ await retryWithPolicy(
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
+ });
272
+ const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
273
+ const bootAlreadyDone =
274
+ bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
275
+ if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
276
+ throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
277
+ stdout: bootResult.stdout,
278
+ stderr: bootResult.stderr,
279
+ exitCode: bootResult.exitCode,
280
+ });
281
+ }
282
+ bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
283
+ allowFailure: true,
284
+ timeoutMs: remainingMs,
285
+ });
286
+ if (bootStatusResult.exitCode !== 0) {
287
+ throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
288
+ stdout: bootStatusResult.stdout,
289
+ stderr: bootStatusResult.stderr,
290
+ exitCode: bootStatusResult.exitCode,
291
+ });
292
+ }
293
+ const nextState = await getSimulatorState(device.id);
294
+ if (nextState !== 'Booted') {
295
+ throw new AppError('COMMAND_FAILED', 'Simulator is still booting', {
296
+ state: nextState,
297
+ });
298
+ }
299
+ },
300
+ {
301
+ maxAttempts: 3,
302
+ baseDelayMs: 500,
303
+ maxDelayMs: 2000,
304
+ jitter: 0.2,
305
+ shouldRetry: (error) => {
306
+ const reason = classifyBootFailure({
307
+ error,
308
+ stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
309
+ stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
310
+ context: { platform: 'ios', phase: 'boot' },
311
+ });
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`);
328
+ },
329
+ },
330
+ );
331
+ } catch (error) {
332
+ const bootStdout = bootResult?.stdout;
333
+ const bootStderr = bootResult?.stderr;
334
+ const bootExitCode = bootResult?.exitCode;
335
+ const bootstatusStdout = bootStatusResult?.stdout;
336
+ const bootstatusStderr = bootStatusResult?.stderr;
337
+ const bootstatusExitCode = bootStatusResult?.exitCode;
338
+ const reason = classifyBootFailure({
339
+ error,
340
+ stdout: bootstatusStdout ?? bootStdout,
341
+ stderr: bootstatusStderr ?? bootStderr,
342
+ context: { platform: 'ios', phase: 'boot' },
343
+ });
344
+ throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
345
+ platform: 'ios',
346
+ deviceId: device.id,
347
+ timeoutMs: IOS_BOOT_TIMEOUT_MS,
348
+ elapsedMs: deadline.elapsedMs(),
349
+ reason,
350
+ hint: bootFailureHint(reason),
351
+ boot: bootResult
352
+ ? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
353
+ : undefined,
354
+ bootstatus: bootStatusResult
355
+ ? {
356
+ exitCode: bootstatusExitCode,
357
+ stdout: bootstatusStdout,
358
+ stderr: bootstatusStderr,
359
+ }
360
+ : undefined,
361
+ });
362
+ }
212
363
  }
213
364
 
214
365
  async function getSimulatorState(udid: string): Promise<string | null> {
215
366
  const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
216
367
  allowFailure: true,
368
+ timeoutMs: TIMEOUT_PROFILES.ios_boot.operationMs,
217
369
  });
218
370
  if (result.exitCode !== 0) return null;
219
371
  try {
@@ -229,3 +381,10 @@ async function getSimulatorState(udid: string): Promise<string | null> {
229
381
  }
230
382
  return null;
231
383
  }
384
+
385
+ function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
386
+ if (!raw) return fallback;
387
+ const parsed = Number(raw);
388
+ if (!Number.isFinite(parsed)) return fallback;
389
+ return Math.max(min, Math.floor(parsed));
390
+ }
@@ -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
+ });
@@ -0,0 +1,44 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { Deadline, retryWithPolicy } from '../retry.ts';
4
+
5
+ test('Deadline tracks remaining and expiration', async () => {
6
+ const deadline = Deadline.fromTimeoutMs(25);
7
+ assert.equal(deadline.isExpired(), false);
8
+ await new Promise((resolve) => setTimeout(resolve, 30));
9
+ assert.equal(deadline.isExpired(), true);
10
+ assert.equal(deadline.remainingMs(), 0);
11
+ });
12
+
13
+ test('retryWithPolicy retries until success', async () => {
14
+ let attempts = 0;
15
+ const result = await retryWithPolicy(
16
+ async () => {
17
+ attempts += 1;
18
+ if (attempts < 3) {
19
+ throw new Error('transient');
20
+ }
21
+ return 'ok';
22
+ },
23
+ { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 },
24
+ );
25
+ assert.equal(result, 'ok');
26
+ assert.equal(attempts, 3);
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
@@ -25,11 +25,12 @@ export type ParsedArgs = {
25
25
  noRecord?: boolean;
26
26
  replayUpdate?: boolean;
27
27
  help: boolean;
28
+ version: boolean;
28
29
  };
29
30
  };
30
31
 
31
32
  export function parseArgs(argv: string[]): ParsedArgs {
32
- const flags: ParsedArgs['flags'] = { json: false, help: false };
33
+ const flags: ParsedArgs['flags'] = { json: false, help: false, version: false };
33
34
  const positionals: string[] = [];
34
35
 
35
36
  for (let i = 0; i < argv.length; i += 1) {
@@ -42,6 +43,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
42
43
  flags.help = true;
43
44
  continue;
44
45
  }
46
+ if (arg === '--version' || arg === '-V') {
47
+ flags.version = true;
48
+ continue;
49
+ }
45
50
  if (arg === '--verbose' || arg === '-v') {
46
51
  flags.verbose = true;
47
52
  continue;
@@ -168,8 +173,10 @@ export function usage(): string {
168
173
  CLI to control iOS and Android devices for AI agents.
169
174
 
170
175
  Commands:
176
+ boot Ensure target device/simulator is booted and ready
171
177
  open [app] Boot device/simulator; optionally launch app
172
178
  close [app] Close app or just end session
179
+ reinstall <app> <path> Uninstall + install app from binary path
173
180
  snapshot [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--backend ax|xctest]
174
181
  Capture accessibility tree
175
182
  -i Interactive elements only
@@ -229,5 +236,6 @@ Flags:
229
236
  --update, -u Replay: update selectors and rewrite replay file in place
230
237
  --user-installed Apps: list user-installed packages (Android only)
231
238
  --all Apps: list all packages (Android only)
239
+ --version, -V Print version and exit
232
240
  `;
233
241
  }
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 {