agent-device 0.3.2 → 0.3.4

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.
@@ -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,77 @@ 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
+ continue;
487
+ }
488
+ const comparison = compareDisambiguationCandidates(node, best);
489
+ if (comparison > 0) {
490
+ best = node;
491
+ tie = false;
492
+ continue;
493
+ }
494
+ if (comparison === 0) {
495
+ tie = true;
496
+ }
497
+ }
498
+ return {
499
+ count,
500
+ firstNode,
501
+ disambiguated: tie ? null : best,
502
+ };
503
+ }
504
+
505
+ function countSelectorMatchesOnly(
506
+ nodes: SnapshotState['nodes'],
507
+ selector: Selector,
508
+ options: { platform: 'ios' | 'android'; requireRect: boolean },
509
+ ): number {
510
+ let count = 0;
511
+ for (const node of nodes) {
512
+ if (options.requireRect && !node.rect) continue;
513
+ if (!matchesSelector(node, selector, options.platform)) continue;
514
+ count += 1;
515
+ }
516
+ return count;
517
+ }
518
+
519
+ function compareDisambiguationCandidates(a: SnapshotNode, b: SnapshotNode): number {
520
+ const depthA = a.depth ?? 0;
521
+ const depthB = b.depth ?? 0;
522
+ if (depthA !== depthB) return depthA > depthB ? 1 : -1;
523
+ const areaA = areaOfNode(a);
524
+ const areaB = areaOfNode(b);
525
+ if (areaA !== areaB) return areaA < areaB ? 1 : -1;
526
+ return 0;
527
+ }
528
+
529
+ function areaOfNode(node: SnapshotNode): number {
530
+ if (!node.rect) return Number.POSITIVE_INFINITY;
531
+ return node.rect.width * node.rect.height;
532
+ }
533
+
534
+ function isEscapedQuote(source: string, index: number): boolean {
535
+ let backslashCount = 0;
536
+ for (let i = index - 1; i >= 0 && source[i] === '\\'; i -= 1) {
537
+ backslashCount += 1;
538
+ }
539
+ return backslashCount % 2 === 1;
540
+ }
@@ -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