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.
@@ -12,6 +12,7 @@ import {
12
12
  formatSelectorFailure,
13
13
  parseSelectorChain,
14
14
  resolveSelectorChain,
15
+ splitIsSelectorArgs,
15
16
  splitSelectorFromArgs,
16
17
  } from '../selectors.ts';
17
18
 
@@ -90,6 +91,7 @@ export async function handleInteractionCommands(params: {
90
91
  platform: session.device.platform,
91
92
  requireRect: true,
92
93
  requireUnique: true,
94
+ disambiguateAmbiguous: true,
93
95
  });
94
96
  if (!resolved || !resolved.node.rect) {
95
97
  return {
@@ -180,7 +182,7 @@ export async function handleInteractionCommands(params: {
180
182
  error: { code: 'SESSION_NOT_FOUND', message: 'No active session. Run open first.' },
181
183
  };
182
184
  }
183
- const selectorArgs = splitSelectorFromArgs(req.positionals ?? []);
185
+ const selectorArgs = splitSelectorFromArgs(req.positionals ?? [], { preferTrailingValue: true });
184
186
  if (selectorArgs) {
185
187
  if (selectorArgs.rest.length === 0) {
186
188
  return { ok: false, error: { code: 'INVALID_ARGS', message: 'fill requires text after selector' } };
@@ -197,6 +199,7 @@ export async function handleInteractionCommands(params: {
197
199
  platform: session.device.platform,
198
200
  requireRect: true,
199
201
  requireUnique: true,
202
+ disambiguateAmbiguous: true,
200
203
  });
201
204
  if (!resolved || !resolved.node.rect) {
202
205
  return {
@@ -367,8 +370,7 @@ export async function handleInteractionCommands(params: {
367
370
  error: { code: 'UNSUPPORTED_OPERATION', message: 'is is not supported on this device' },
368
371
  };
369
372
  }
370
- const selectorArgs = req.positionals.slice(1);
371
- const split = splitSelectorFromArgs(selectorArgs);
373
+ const { split } = splitIsSelectorArgs(req.positionals);
372
374
  if (!split) {
373
375
  return {
374
376
  ok: false,
@@ -11,9 +11,48 @@ import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
11
11
  import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
12
12
  import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
13
13
  import { pruneGroupNodes } from '../snapshot-processing.ts';
14
- import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
14
+ import {
15
+ buildSelectorChainForNode,
16
+ resolveSelectorChain,
17
+ splitIsSelectorArgs,
18
+ splitSelectorFromArgs,
19
+ tryParseSelectorChain,
20
+ } from '../selectors.ts';
15
21
  import { inferFillText, uniqueStrings } from '../action-utils.ts';
16
22
 
23
+ type ReinstallOps = {
24
+ ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
25
+ android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
26
+ };
27
+
28
+ function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
29
+ return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
30
+ }
31
+
32
+ async function resolveCommandDevice(params: {
33
+ session: SessionState | undefined;
34
+ flags: DaemonRequest['flags'] | undefined;
35
+ ensureReadyFn: typeof ensureDeviceReady;
36
+ ensureReady?: boolean;
37
+ }): Promise<DeviceInfo> {
38
+ const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {}));
39
+ if (params.ensureReady !== false) {
40
+ await params.ensureReadyFn(device);
41
+ }
42
+ return device;
43
+ }
44
+
45
+ const defaultReinstallOps: ReinstallOps = {
46
+ ios: async (device, app, appPath) => {
47
+ const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
48
+ return await reinstallIosApp(device, app, appPath);
49
+ },
50
+ android: async (device, app, appPath) => {
51
+ const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
52
+ return await reinstallAndroidApp(device, app, appPath);
53
+ },
54
+ };
55
+
17
56
  export async function handleSessionCommands(params: {
18
57
  req: DaemonRequest;
19
58
  sessionName: string;
@@ -21,9 +60,21 @@ export async function handleSessionCommands(params: {
21
60
  sessionStore: SessionStore;
22
61
  invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
23
62
  dispatch?: typeof dispatchCommand;
63
+ ensureReady?: typeof ensureDeviceReady;
64
+ reinstallOps?: ReinstallOps;
24
65
  }): Promise<DaemonResponse | null> {
25
- const { req, sessionName, logPath, sessionStore, invoke, dispatch: dispatchOverride } = params;
66
+ const {
67
+ req,
68
+ sessionName,
69
+ logPath,
70
+ sessionStore,
71
+ invoke,
72
+ dispatch: dispatchOverride,
73
+ ensureReady: ensureReadyOverride,
74
+ reinstallOps = defaultReinstallOps,
75
+ } = params;
26
76
  const dispatch = dispatchOverride ?? dispatchCommand;
77
+ const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
27
78
  const command = req.command;
28
79
 
29
80
  if (command === 'session_list') {
@@ -72,7 +123,7 @@ export async function handleSessionCommands(params: {
72
123
  if (command === 'apps') {
73
124
  const session = sessionStore.get(sessionName);
74
125
  const flags = req.flags ?? {};
75
- if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) {
126
+ if (!session && !hasExplicitDeviceSelector(flags)) {
76
127
  return {
77
128
  ok: false,
78
129
  error: {
@@ -81,8 +132,7 @@ export async function handleSessionCommands(params: {
81
132
  },
82
133
  };
83
134
  }
84
- const device = session?.device ?? (await resolveTargetDevice(flags));
85
- await ensureDeviceReady(device);
135
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
86
136
  if (!isCommandSupportedOnDevice('apps', device)) {
87
137
  return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
88
138
  }
@@ -106,11 +156,39 @@ export async function handleSessionCommands(params: {
106
156
  return { ok: true, data: { apps } };
107
157
  }
108
158
 
109
- if (command === 'appstate') {
159
+ if (command === 'boot') {
110
160
  const session = sessionStore.get(sessionName);
111
161
  const flags = req.flags ?? {};
162
+ if (!session && !hasExplicitDeviceSelector(flags)) {
163
+ return {
164
+ ok: false,
165
+ error: {
166
+ code: 'INVALID_ARGS',
167
+ message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
168
+ },
169
+ };
170
+ }
112
171
  const device = session?.device ?? (await resolveTargetDevice(flags));
113
- await ensureDeviceReady(device);
172
+ if (!isCommandSupportedOnDevice('boot', device)) {
173
+ return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
174
+ }
175
+ await ensureReady(device);
176
+ return {
177
+ ok: true,
178
+ data: {
179
+ platform: device.platform,
180
+ device: device.name,
181
+ id: device.id,
182
+ kind: device.kind,
183
+ booted: true,
184
+ },
185
+ };
186
+ }
187
+
188
+ if (command === 'appstate') {
189
+ const session = sessionStore.get(sessionName);
190
+ const flags = req.flags ?? {};
191
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
114
192
  if (device.platform === 'ios') {
115
193
  if (session?.appBundleId) {
116
194
  return {
@@ -151,6 +229,62 @@ export async function handleSessionCommands(params: {
151
229
  };
152
230
  }
153
231
 
232
+ if (command === 'reinstall') {
233
+ const session = sessionStore.get(sessionName);
234
+ const flags = req.flags ?? {};
235
+ if (!session && !hasExplicitDeviceSelector(flags)) {
236
+ return {
237
+ ok: false,
238
+ error: {
239
+ code: 'INVALID_ARGS',
240
+ message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).',
241
+ },
242
+ };
243
+ }
244
+ const app = req.positionals?.[0]?.trim();
245
+ const appPathInput = req.positionals?.[1]?.trim();
246
+ if (!app || !appPathInput) {
247
+ return {
248
+ ok: false,
249
+ error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
250
+ };
251
+ }
252
+ const appPath = SessionStore.expandHome(appPathInput);
253
+ if (!fs.existsSync(appPath)) {
254
+ return {
255
+ ok: false,
256
+ error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
257
+ };
258
+ }
259
+ const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false });
260
+ if (!isCommandSupportedOnDevice('reinstall', device)) {
261
+ return {
262
+ ok: false,
263
+ error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
264
+ };
265
+ }
266
+ let reinstallData:
267
+ | { platform: 'ios'; appId: string; bundleId: string }
268
+ | { platform: 'android'; appId: string; package: string };
269
+ if (device.platform === 'ios') {
270
+ const iosResult = await reinstallOps.ios(device, app, appPath);
271
+ reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
272
+ } else {
273
+ const androidResult = await reinstallOps.android(device, app, appPath);
274
+ reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
275
+ }
276
+ const result = { app, appPath, ...reinstallData };
277
+ if (session) {
278
+ sessionStore.recordAction(session, {
279
+ command,
280
+ positionals: req.positionals ?? [],
281
+ flags: req.flags ?? {},
282
+ result,
283
+ });
284
+ }
285
+ return { ok: true, data: result };
286
+ }
287
+
154
288
  if (command === 'open') {
155
289
  if (sessionStore.has(sessionName)) {
156
290
  const session = sessionStore.get(sessionName);
@@ -193,7 +327,6 @@ export async function handleSessionCommands(params: {
193
327
  return { ok: true, data: { session: sessionName, appName, appBundleId } };
194
328
  }
195
329
  const device = await resolveTargetDevice(req.flags ?? {});
196
- await ensureDeviceReady(device);
197
330
  const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
198
331
  if (inUse) {
199
332
  return {
@@ -269,7 +402,9 @@ export async function handleSessionCommands(params: {
269
402
  flags: action.flags ?? {},
270
403
  });
271
404
  if (response.ok) continue;
272
- if (!shouldUpdate) return response;
405
+ if (!shouldUpdate) {
406
+ return withReplayFailureContext(response, action, index, resolved);
407
+ }
273
408
  const nextAction = await healReplayAction({
274
409
  action,
275
410
  sessionName,
@@ -278,7 +413,7 @@ export async function handleSessionCommands(params: {
278
413
  dispatch,
279
414
  });
280
415
  if (!nextAction) {
281
- return response;
416
+ return withReplayFailureContext(response, action, index, resolved);
282
417
  }
283
418
  actions[index] = nextAction;
284
419
  response = await invoke({
@@ -289,7 +424,7 @@ export async function handleSessionCommands(params: {
289
424
  flags: nextAction.flags ?? {},
290
425
  });
291
426
  if (!response.ok) {
292
- return response;
427
+ return withReplayFailureContext(response, nextAction, index, resolved);
293
428
  }
294
429
  healed += 1;
295
430
  }
@@ -334,6 +469,42 @@ export async function handleSessionCommands(params: {
334
469
  return null;
335
470
  }
336
471
 
472
+ function withReplayFailureContext(
473
+ response: DaemonResponse,
474
+ action: SessionAction,
475
+ index: number,
476
+ replayPath: string,
477
+ ): DaemonResponse {
478
+ if (response.ok) return response;
479
+ const step = index + 1;
480
+ const summary = formatReplayActionSummary(action);
481
+ const details = {
482
+ ...(response.error.details ?? {}),
483
+ replayPath,
484
+ step,
485
+ action: action.command,
486
+ positionals: action.positionals ?? [],
487
+ };
488
+ return {
489
+ ok: false,
490
+ error: {
491
+ code: response.error.code,
492
+ message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
493
+ details,
494
+ },
495
+ };
496
+ }
497
+
498
+ function formatReplayActionSummary(action: SessionAction): string {
499
+ const values = (action.positionals ?? []).map((value) => {
500
+ const trimmed = value.trim();
501
+ if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
502
+ if (trimmed.startsWith('@')) return trimmed;
503
+ return JSON.stringify(trimmed);
504
+ });
505
+ return [action.command, ...values].join(' ');
506
+ }
507
+
337
508
  async function healReplayAction(params: {
338
509
  action: SessionAction;
339
510
  sessionName: string;
@@ -349,11 +520,13 @@ async function healReplayAction(params: {
349
520
  const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
350
521
  const selectorCandidates = collectReplaySelectorCandidates(action);
351
522
  for (const candidate of selectorCandidates) {
352
- const chain = parseSelectorChain(candidate);
523
+ const chain = tryParseSelectorChain(candidate);
524
+ if (!chain) continue;
353
525
  const resolved = resolveSelectorChain(snapshot.nodes, chain, {
354
526
  platform: session.device.platform,
355
527
  requireRect: requiresRect,
356
528
  requireUnique: true,
529
+ disambiguateAmbiguous: requiresRect,
357
530
  });
358
531
  if (!resolved) continue;
359
532
  const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
@@ -383,9 +556,8 @@ async function healReplayAction(params: {
383
556
  };
384
557
  }
385
558
  if (action.command === 'is') {
386
- const predicate = action.positionals?.[0];
559
+ const { predicate, split } = splitIsSelectorArgs(action.positionals);
387
560
  if (!predicate) continue;
388
- const split = splitSelectorFromArgs(action.positionals.slice(1));
389
561
  const expectedText = split?.rest.join(' ').trim() ?? '';
390
562
  const nextPositionals = [predicate, selectorExpression];
391
563
  if (predicate === 'text' && expectedText.length > 0) {
@@ -476,7 +648,7 @@ function collectReplaySelectorCandidates(action: SessionAction): string[] {
476
648
  }
477
649
  }
478
650
  if (action.command === 'is') {
479
- const split = splitSelectorFromArgs(action.positionals.slice(1));
651
+ const { split } = splitIsSelectorArgs(action.positionals);
480
652
  if (split) {
481
653
  result.push(split.selectorExpression);
482
654
  }
@@ -85,25 +85,38 @@ export function resolveSelectorChain(
85
85
  platform: 'ios' | 'android';
86
86
  requireRect?: boolean;
87
87
  requireUnique?: boolean;
88
+ disambiguateAmbiguous?: boolean;
88
89
  },
89
90
  ): SelectorResolution | null {
90
91
  const requireRect = options.requireRect ?? false;
91
92
  const requireUnique = options.requireUnique ?? true;
93
+ const disambiguateAmbiguous = options.disambiguateAmbiguous ?? false;
92
94
  const diagnostics: SelectorDiagnostics[] = [];
93
95
  for (let i = 0; i < chain.selectors.length; i += 1) {
94
96
  const selector = chain.selectors[i];
95
- const matches = nodes.filter((node) => {
96
- if (requireRect && !node.rect) return false;
97
- return matchesSelector(node, selector, options.platform);
97
+ const summary = analyzeSelectorMatches(nodes, selector, {
98
+ platform: options.platform,
99
+ requireRect,
98
100
  });
99
- diagnostics.push({ selector: selector.raw, matches: matches.length });
100
- if (matches.length === 0) continue;
101
- if (requireUnique && matches.length !== 1) continue;
101
+ diagnostics.push({ selector: selector.raw, matches: summary.count });
102
+ if (summary.count === 0 || !summary.firstNode) continue;
103
+ if (requireUnique && summary.count !== 1) {
104
+ if (!disambiguateAmbiguous) continue;
105
+ const disambiguatedNode = summary.disambiguated;
106
+ if (!disambiguatedNode) continue;
107
+ return {
108
+ node: disambiguatedNode,
109
+ selector,
110
+ selectorIndex: i,
111
+ matches: summary.count,
112
+ diagnostics,
113
+ };
114
+ }
102
115
  return {
103
- node: matches[0],
116
+ node: summary.firstNode,
104
117
  selector,
105
118
  selectorIndex: i,
106
- matches: matches.length,
119
+ matches: summary.count,
107
120
  diagnostics,
108
121
  };
109
122
  }
@@ -122,13 +135,13 @@ export function findSelectorChainMatch(
122
135
  const diagnostics: SelectorDiagnostics[] = [];
123
136
  for (let i = 0; i < chain.selectors.length; i += 1) {
124
137
  const selector = chain.selectors[i];
125
- const matches = nodes.filter((node) => {
126
- if (requireRect && !node.rect) return false;
127
- return matchesSelector(node, selector, options.platform);
138
+ const matches = countSelectorMatchesOnly(nodes, selector, {
139
+ platform: options.platform,
140
+ requireRect,
128
141
  });
129
- diagnostics.push({ selector: selector.raw, matches: matches.length });
130
- if (matches.length > 0) {
131
- return { selectorIndex: i, selector, matches: matches.length, diagnostics };
142
+ diagnostics.push({ selector: selector.raw, matches });
143
+ if (matches > 0) {
144
+ return { selectorIndex: i, selector, matches, diagnostics };
132
145
  }
133
146
  }
134
147
  return null;
@@ -162,21 +175,51 @@ export function isSelectorToken(token: string): boolean {
162
175
  return ALL_KEYS.has(trimmed.toLowerCase() as SelectorKey);
163
176
  }
164
177
 
165
- export function splitSelectorFromArgs(args: string[]): { selectorExpression: string; rest: string[] } | null {
178
+ export function splitSelectorFromArgs(
179
+ args: string[],
180
+ options: { preferTrailingValue?: boolean } = {},
181
+ ): { selectorExpression: string; rest: string[] } | null {
166
182
  if (args.length === 0) return null;
183
+ const preferTrailingValue = options.preferTrailingValue ?? false;
167
184
  let i = 0;
185
+ const boundaries: number[] = [];
168
186
  while (i < args.length && isSelectorToken(args[i])) {
169
187
  i += 1;
188
+ const candidate = args.slice(0, i).join(' ').trim();
189
+ if (!candidate) continue;
190
+ if (tryParseSelectorChain(candidate)) {
191
+ boundaries.push(i);
192
+ }
193
+ }
194
+ if (boundaries.length === 0) return null;
195
+ let boundary = boundaries[boundaries.length - 1];
196
+ if (preferTrailingValue) {
197
+ for (let j = boundaries.length - 1; j >= 0; j -= 1) {
198
+ if (boundaries[j] < args.length) {
199
+ boundary = boundaries[j];
200
+ break;
201
+ }
202
+ }
170
203
  }
171
- if (i === 0) return null;
172
- const selectorExpression = args.slice(0, i).join(' ').trim();
204
+ const selectorExpression = args.slice(0, boundary).join(' ').trim();
173
205
  if (!selectorExpression) return null;
174
206
  return {
175
207
  selectorExpression,
176
- rest: args.slice(i),
208
+ rest: args.slice(boundary),
177
209
  };
178
210
  }
179
211
 
212
+ export function splitIsSelectorArgs(positionals: string[]): {
213
+ predicate: string;
214
+ split: { selectorExpression: string; rest: string[] } | null;
215
+ } {
216
+ const predicate = positionals[0] ?? '';
217
+ const split = splitSelectorFromArgs(positionals.slice(1), {
218
+ preferTrailingValue: predicate === 'text',
219
+ });
220
+ return { predicate, split };
221
+ }
222
+
180
223
  export function isNodeVisible(node: SnapshotNode): boolean {
181
224
  if (node.hittable === true) return true;
182
225
  if (!node.rect) return false;
@@ -318,7 +361,7 @@ function splitByFallback(expression: string): string[] {
318
361
  let quote: '"' | "'" | null = null;
319
362
  for (let i = 0; i < expression.length; i += 1) {
320
363
  const ch = expression[i];
321
- if ((ch === '"' || ch === "'") && expression[i - 1] !== '\\') {
364
+ if ((ch === '"' || ch === "'") && !isEscapedQuote(expression, i)) {
322
365
  if (!quote) {
323
366
  quote = ch;
324
367
  } else if (quote === ch) {
@@ -353,7 +396,7 @@ function tokenize(segment: string): string[] {
353
396
  let quote: '"' | "'" | null = null;
354
397
  for (let i = 0; i < segment.length; i += 1) {
355
398
  const ch = segment[i];
356
- if ((ch === '"' || ch === "'") && segment[i - 1] !== '\\') {
399
+ if ((ch === '"' || ch === "'") && !isEscapedQuote(segment, i)) {
357
400
  if (!quote) {
358
401
  quote = ch;
359
402
  } else if (quote === ch) {
@@ -421,3 +464,78 @@ function normalizeSelectorText(value: string | undefined): string | null {
421
464
  if (!trimmed) return null;
422
465
  return trimmed;
423
466
  }
467
+
468
+ function analyzeSelectorMatches(
469
+ nodes: SnapshotState['nodes'],
470
+ selector: Selector,
471
+ options: { platform: 'ios' | 'android'; requireRect: boolean },
472
+ ): { count: number; firstNode: SnapshotNode | null; disambiguated: SnapshotNode | null } {
473
+ let count = 0;
474
+ let firstNode: SnapshotNode | null = null;
475
+ let best: SnapshotNode | null = null;
476
+ let tie = false;
477
+ for (const node of nodes) {
478
+ if (options.requireRect && !node.rect) continue;
479
+ if (!matchesSelector(node, selector, options.platform)) continue;
480
+ count += 1;
481
+ if (!firstNode) {
482
+ firstNode = node;
483
+ }
484
+ if (!best) {
485
+ best = node;
486
+ tie = false;
487
+ continue;
488
+ }
489
+ const comparison = compareDisambiguationCandidates(node, best);
490
+ if (comparison > 0) {
491
+ best = node;
492
+ tie = false;
493
+ continue;
494
+ }
495
+ if (comparison === 0) {
496
+ tie = true;
497
+ }
498
+ }
499
+ return {
500
+ count,
501
+ firstNode,
502
+ disambiguated: tie ? null : best,
503
+ };
504
+ }
505
+
506
+ function countSelectorMatchesOnly(
507
+ nodes: SnapshotState['nodes'],
508
+ selector: Selector,
509
+ options: { platform: 'ios' | 'android'; requireRect: boolean },
510
+ ): number {
511
+ let count = 0;
512
+ for (const node of nodes) {
513
+ if (options.requireRect && !node.rect) continue;
514
+ if (!matchesSelector(node, selector, options.platform)) continue;
515
+ count += 1;
516
+ }
517
+ return count;
518
+ }
519
+
520
+ function compareDisambiguationCandidates(a: SnapshotNode, b: SnapshotNode): number {
521
+ const depthA = a.depth ?? 0;
522
+ const depthB = b.depth ?? 0;
523
+ if (depthA !== depthB) return depthA > depthB ? 1 : -1;
524
+ const areaA = areaOfNode(a);
525
+ const areaB = areaOfNode(b);
526
+ if (areaA !== areaB) return areaA < areaB ? 1 : -1;
527
+ return 0;
528
+ }
529
+
530
+ function areaOfNode(node: SnapshotNode): number {
531
+ if (!node.rect) return Number.POSITIVE_INFINITY;
532
+ return node.rect.width * node.rect.height;
533
+ }
534
+
535
+ function isEscapedQuote(source: string, index: number): boolean {
536
+ let backslashCount = 0;
537
+ for (let i = index - 1; i >= 0 && source[i] === '\\'; i -= 1) {
538
+ backslashCount += 1;
539
+ }
540
+ return backslashCount % 2 === 1;
541
+ }
@@ -78,10 +78,14 @@ export function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] {
78
78
  }
79
79
 
80
80
  export function normalizeType(type: string): string {
81
- let value = type.replace(/XCUIElementType/gi, '').toLowerCase();
81
+ let value = type.trim().replace(/XCUIElementType/gi, '').toLowerCase();
82
82
  if (value.startsWith('ax')) {
83
83
  value = value.replace(/^ax/, '');
84
84
  }
85
+ const lastSeparator = Math.max(value.lastIndexOf('.'), value.lastIndexOf('/'));
86
+ if (lastSeparator !== -1) {
87
+ value = value.slice(lastSeparator + 1);
88
+ }
85
89
  return value;
86
90
  }
87
91
 
@@ -1,23 +1,38 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { classifyBootFailure } from '../boot-diagnostics.ts';
3
+ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
4
4
  import { AppError } from '../../utils/errors.ts';
5
5
 
6
6
  test('classifyBootFailure maps timeout errors', () => {
7
- const reason = classifyBootFailure({ message: 'bootstatus timed out after 120s' });
8
- assert.equal(reason, 'BOOT_TIMEOUT');
7
+ const reason = classifyBootFailure({
8
+ message: 'bootstatus timed out after 120s',
9
+ context: { platform: 'ios', phase: 'boot' },
10
+ });
11
+ assert.equal(reason, 'IOS_BOOT_TIMEOUT');
9
12
  });
10
13
 
11
14
  test('classifyBootFailure maps adb offline errors', () => {
12
- const reason = classifyBootFailure({ stderr: 'error: device offline' });
13
- assert.equal(reason, 'DEVICE_OFFLINE');
15
+ const reason = classifyBootFailure({
16
+ stderr: 'error: device offline',
17
+ context: { platform: 'android', phase: 'transport' },
18
+ });
19
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
14
20
  });
15
21
 
16
- test('classifyBootFailure maps tool missing from AppError code', () => {
22
+ test('classifyBootFailure maps tool missing from AppError code (android)', () => {
17
23
  const reason = classifyBootFailure({
18
24
  error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
25
+ context: { platform: 'android', phase: 'transport' },
26
+ });
27
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
28
+ });
29
+
30
+ test('classifyBootFailure maps tool missing from AppError code (ios)', () => {
31
+ const reason = classifyBootFailure({
32
+ error: new AppError('TOOL_MISSING', 'xcrun not found in PATH'),
33
+ context: { platform: 'ios', phase: 'boot' },
19
34
  });
20
- assert.equal(reason, 'TOOL_MISSING');
35
+ assert.equal(reason, 'IOS_TOOL_MISSING');
21
36
  });
22
37
 
23
38
  test('classifyBootFailure reads stderr from AppError details', () => {
@@ -25,6 +40,20 @@ test('classifyBootFailure reads stderr from AppError details', () => {
25
40
  error: new AppError('COMMAND_FAILED', 'adb failed', {
26
41
  stderr: 'error: device unauthorized',
27
42
  }),
43
+ context: { platform: 'android', phase: 'transport' },
44
+ });
45
+ assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
46
+ });
47
+
48
+ test('bootFailureHint returns actionable guidance', () => {
49
+ const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
50
+ assert.equal(hint.includes('xcodebuild logs'), true);
51
+ });
52
+
53
+ test('connect phase does not classify non-timeout errors as connect timeout', () => {
54
+ const reason = classifyBootFailure({
55
+ message: 'Runner returned malformed JSON payload',
56
+ context: { platform: 'ios', phase: 'connect' },
28
57
  });
29
- assert.equal(reason, 'PERMISSION_DENIED');
58
+ assert.equal(reason, 'BOOT_COMMAND_FAILED');
30
59
  });
@@ -1,5 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
+ import { parseAndroidLaunchComponent } from '../index.ts';
3
4
  import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
4
5
 
5
6
  test('parseUiHierarchy reads double-quoted Android node attributes', () => {
@@ -72,3 +73,19 @@ test('findBounds ignores bounds-like fragments inside other attribute values', (
72
73
 
73
74
  assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
74
75
  });
76
+
77
+ test('parseAndroidLaunchComponent extracts final resolved component', () => {
78
+ const stdout = [
79
+ 'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
80
+ 'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
81
+ ].join('\n');
82
+ assert.equal(
83
+ parseAndroidLaunchComponent(stdout),
84
+ 'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
85
+ );
86
+ });
87
+
88
+ test('parseAndroidLaunchComponent returns null when no component is present', () => {
89
+ const stdout = 'No activity found';
90
+ assert.equal(parseAndroidLaunchComponent(stdout), null);
91
+ });