agent-device 0.2.1 → 0.2.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.
@@ -148,25 +148,58 @@ function parseAndroidFocus(text: string): { package?: string; activity?: string
148
148
  return null;
149
149
  }
150
150
 
151
- export async function openAndroidApp(device: DeviceInfo, app: string): Promise<void> {
151
+ export async function openAndroidApp(
152
+ device: DeviceInfo,
153
+ app: string,
154
+ activity?: string,
155
+ ): Promise<void> {
152
156
  if (!device.booted) {
153
157
  await waitForAndroidBoot(device.id);
154
158
  }
155
159
  const resolved = await resolveAndroidApp(device, app);
156
160
  if (resolved.type === 'intent') {
161
+ if (activity) {
162
+ throw new AppError('INVALID_ARGS', 'Activity override requires a package name, not an intent');
163
+ }
157
164
  await runCmd('adb', adbArgs(device, ['shell', 'am', 'start', '-a', resolved.value]));
158
165
  return;
159
166
  }
167
+ if (activity) {
168
+ const component = activity.includes('/')
169
+ ? activity
170
+ : `${resolved.value}/${activity.startsWith('.') ? activity : `.${activity}`}`;
171
+ await runCmd(
172
+ 'adb',
173
+ adbArgs(device, [
174
+ 'shell',
175
+ 'am',
176
+ 'start',
177
+ '-a',
178
+ 'android.intent.action.MAIN',
179
+ '-c',
180
+ 'android.intent.category.DEFAULT',
181
+ '-c',
182
+ 'android.intent.category.LAUNCHER',
183
+ '-n',
184
+ component,
185
+ ]),
186
+ );
187
+ return;
188
+ }
160
189
  await runCmd(
161
190
  'adb',
162
191
  adbArgs(device, [
163
192
  'shell',
164
- 'monkey',
165
- '-p',
166
- resolved.value,
193
+ 'am',
194
+ 'start',
195
+ '-a',
196
+ 'android.intent.action.MAIN',
197
+ '-c',
198
+ 'android.intent.category.DEFAULT',
167
199
  '-c',
168
200
  'android.intent.category.LAUNCHER',
169
- '1',
201
+ '-p',
202
+ resolved.value,
170
203
  ]),
171
204
  );
172
205
  }
@@ -242,8 +275,30 @@ export async function fillAndroid(
242
275
  y: number,
243
276
  text: string,
244
277
  ): Promise<void> {
278
+ const attempts = [
279
+ { clearPadding: 12, minClear: 8, maxClear: 48, chunkSize: 4, delayMs: 0 },
280
+ { clearPadding: 24, minClear: 16, maxClear: 96, chunkSize: 1, delayMs: 15 },
281
+ ] as const;
282
+
245
283
  await focusAndroid(device, x, y);
246
- await typeAndroid(device, text);
284
+ let lastActual: string | null = null;
285
+
286
+ for (const attempt of attempts) {
287
+ const clearCount = clampCount(
288
+ text.length + attempt.clearPadding,
289
+ attempt.minClear,
290
+ attempt.maxClear,
291
+ );
292
+ await clearFocusedText(device, clearCount);
293
+ await typeAndroidChunked(device, text, attempt.chunkSize, attempt.delayMs);
294
+ lastActual = await readInputValueAtPoint(device, x, y);
295
+ if (lastActual === text) return;
296
+ }
297
+
298
+ throw new AppError('COMMAND_FAILED', 'Android fill verification failed', {
299
+ expected: text,
300
+ actual: lastActual ?? null,
301
+ });
247
302
  }
248
303
 
249
304
  export async function scrollAndroid(
@@ -422,6 +477,112 @@ function parseSettingState(state: string): boolean {
422
477
  throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
423
478
  }
424
479
 
480
+ async function typeAndroidChunked(
481
+ device: DeviceInfo,
482
+ text: string,
483
+ chunkSize: number,
484
+ delayMs: number,
485
+ ): Promise<void> {
486
+ const size = Math.max(1, Math.floor(chunkSize));
487
+ for (let i = 0; i < text.length; i += size) {
488
+ const chunk = text.slice(i, i + size);
489
+ await typeAndroid(device, chunk);
490
+ if (delayMs > 0 && i + size < text.length) {
491
+ await sleep(delayMs);
492
+ }
493
+ }
494
+ }
495
+
496
+ async function clearFocusedText(device: DeviceInfo, count: number): Promise<void> {
497
+ const deletes = Math.max(0, count);
498
+ await runCmd('adb', adbArgs(device, ['shell', 'input', 'keyevent', 'KEYCODE_MOVE_END']), {
499
+ allowFailure: true,
500
+ });
501
+ const batchSize = 24;
502
+ for (let i = 0; i < deletes; i += batchSize) {
503
+ const size = Math.min(batchSize, deletes - i);
504
+ await runCmd(
505
+ 'adb',
506
+ adbArgs(device, ['shell', 'input', 'keyevent', ...Array(size).fill('KEYCODE_DEL')]),
507
+ {
508
+ allowFailure: true,
509
+ },
510
+ );
511
+ }
512
+ }
513
+
514
+ async function readInputValueAtPoint(
515
+ device: DeviceInfo,
516
+ x: number,
517
+ y: number,
518
+ ): Promise<string | null> {
519
+ const xml = await dumpUiHierarchy(device);
520
+ const nodeRegex = /<node\b[^>]*>/g;
521
+ let match: RegExpExecArray | null;
522
+ let focusedEdit: { text: string; area: number } | null = null;
523
+ let editAtPoint: { text: string; area: number } | null = null;
524
+ let anyAtPoint: { text: string; area: number } | null = null;
525
+
526
+ while ((match = nodeRegex.exec(xml)) !== null) {
527
+ const node = match[0];
528
+ const attrs = readNodeAttributes(node);
529
+ const rect = parseBounds(attrs.bounds);
530
+ if (!rect) continue;
531
+ const className = attrs.className ?? '';
532
+ const text = decodeXmlEntities(attrs.text ?? '');
533
+ const focused = attrs.focused ?? false;
534
+ if (!text) continue;
535
+ const area = Math.max(1, rect.width * rect.height);
536
+ const containsPoint =
537
+ x >= rect.x &&
538
+ x <= rect.x + rect.width &&
539
+ y >= rect.y &&
540
+ y <= rect.y + rect.height;
541
+
542
+ if (focused && isEditTextClass(className)) {
543
+ if (!focusedEdit || area <= focusedEdit.area) {
544
+ focusedEdit = { text, area };
545
+ }
546
+ continue;
547
+ }
548
+ if (containsPoint && isEditTextClass(className)) {
549
+ if (!editAtPoint || area <= editAtPoint.area) {
550
+ editAtPoint = { text, area };
551
+ }
552
+ continue;
553
+ }
554
+ if (containsPoint) {
555
+ if (!anyAtPoint || area <= anyAtPoint.area) {
556
+ anyAtPoint = { text, area };
557
+ }
558
+ }
559
+ }
560
+
561
+ return focusedEdit?.text ?? editAtPoint?.text ?? anyAtPoint?.text ?? null;
562
+ }
563
+
564
+ function isEditTextClass(className: string): boolean {
565
+ const lower = className.toLowerCase();
566
+ return lower.includes('edittext') || lower.includes('textfield');
567
+ }
568
+
569
+ function decodeXmlEntities(value: string): string {
570
+ return value
571
+ .replace(/&quot;/g, '"')
572
+ .replace(/&apos;/g, "'")
573
+ .replace(/&lt;/g, '<')
574
+ .replace(/&gt;/g, '>')
575
+ .replace(/&amp;/g, '&');
576
+ }
577
+
578
+ async function sleep(ms: number): Promise<void> {
579
+ await new Promise((resolve) => setTimeout(resolve, ms));
580
+ }
581
+
582
+ function clampCount(value: number, min: number, max: number): number {
583
+ return Math.max(min, Math.min(max, value));
584
+ }
585
+
425
586
  function findBounds(xml: string, query: string): { x: number; y: number } | null {
426
587
  const q = query.toLowerCase();
427
588
  const nodeRegex = /<node[^>]+>/g;
@@ -460,17 +621,47 @@ function parseUiHierarchy(
460
621
  const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
461
622
  const roots = scopedRoot ? [scopedRoot] : tree.children;
462
623
 
463
- const walk = (node: AndroidNode, depth: number) => {
624
+ const interactiveDescendantMemo = new Map<AndroidNode, boolean>();
625
+ const hasInteractiveDescendant = (node: AndroidNode): boolean => {
626
+ const cached = interactiveDescendantMemo.get(node);
627
+ if (cached !== undefined) return cached;
628
+ for (const child of node.children) {
629
+ if (child.hittable || hasInteractiveDescendant(child)) {
630
+ interactiveDescendantMemo.set(node, true);
631
+ return true;
632
+ }
633
+ }
634
+ interactiveDescendantMemo.set(node, false);
635
+ return false;
636
+ };
637
+
638
+ const walk = (
639
+ node: AndroidNode,
640
+ depth: number,
641
+ parentIndex?: number,
642
+ ancestorHittable: boolean = false,
643
+ ancestorCollection: boolean = false,
644
+ ) => {
464
645
  if (nodes.length >= maxNodes) {
465
646
  truncated = true;
466
647
  return;
467
648
  }
468
649
  if (depth > maxDepth) return;
469
650
 
470
- const include = options.raw ? true : shouldIncludeAndroidNode(node, options);
651
+ const include = options.raw
652
+ ? true
653
+ : shouldIncludeAndroidNode(
654
+ node,
655
+ options,
656
+ ancestorHittable,
657
+ hasInteractiveDescendant(node),
658
+ ancestorCollection,
659
+ );
660
+ let currentIndex = parentIndex;
471
661
  if (include) {
662
+ currentIndex = nodes.length;
472
663
  nodes.push({
473
- index: nodes.length,
664
+ index: currentIndex,
474
665
  type: node.type ?? undefined,
475
666
  label: node.label ?? undefined,
476
667
  value: node.value ?? undefined,
@@ -479,17 +670,19 @@ function parseUiHierarchy(
479
670
  enabled: node.enabled,
480
671
  hittable: node.hittable,
481
672
  depth,
482
- parentIndex: node.parentIndex,
673
+ parentIndex,
483
674
  });
484
675
  }
676
+ const nextAncestorHittable = ancestorHittable || Boolean(node.hittable);
677
+ const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type);
485
678
  for (const child of node.children) {
486
- walk(child, depth + 1);
679
+ walk(child, depth + 1, currentIndex, nextAncestorHittable, nextAncestorCollection);
487
680
  if (truncated) return;
488
681
  }
489
682
  };
490
683
 
491
684
  for (const root of roots) {
492
- walk(root, 0);
685
+ walk(root, 0, undefined, false, false);
493
686
  if (truncated) break;
494
687
  }
495
688
 
@@ -505,6 +698,7 @@ function readNodeAttributes(node: string): {
505
698
  clickable?: boolean;
506
699
  enabled?: boolean;
507
700
  focusable?: boolean;
701
+ focused?: boolean;
508
702
  } {
509
703
  const getAttr = (name: string): string | null => {
510
704
  const regex = new RegExp(`${name}="([^"]*)"`);
@@ -525,6 +719,7 @@ function readNodeAttributes(node: string): {
525
719
  clickable: boolAttr('clickable'),
526
720
  enabled: boolAttr('enabled'),
527
721
  focusable: boolAttr('focusable'),
722
+ focused: boolAttr('focused'),
528
723
  };
529
724
  }
530
725
 
@@ -595,18 +790,71 @@ function parseUiHierarchyTree(xml: string): AndroidNode {
595
790
  return root;
596
791
  }
597
792
 
598
- function shouldIncludeAndroidNode(node: AndroidNode, options: SnapshotOptions): boolean {
793
+ function shouldIncludeAndroidNode(
794
+ node: AndroidNode,
795
+ options: SnapshotOptions,
796
+ ancestorHittable: boolean,
797
+ descendantHittable: boolean,
798
+ ancestorCollection: boolean,
799
+ ): boolean {
800
+ const type = normalizeAndroidType(node.type);
801
+ const hasText = Boolean(node.label && node.label.trim().length > 0);
802
+ const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
803
+ const hasMeaningfulText = hasText && !isGenericAndroidId(node.label ?? '');
804
+ const hasMeaningfulId = hasId && !isGenericAndroidId(node.identifier ?? '');
805
+ const isStructural = isStructuralAndroidType(type);
806
+ const isVisual = type === 'imageview' || type === 'imagebutton';
599
807
  if (options.interactiveOnly) {
600
- return Boolean(node.hittable);
808
+ if (node.hittable) return true;
809
+ // Keep text proxies for tappable rows while dropping structural noise.
810
+ const proxyCandidate = hasMeaningfulText || hasMeaningfulId;
811
+ if (!proxyCandidate) return false;
812
+ if (isVisual) return false;
813
+ if (isStructural && !ancestorCollection) return false;
814
+ return ancestorHittable || descendantHittable || ancestorCollection;
601
815
  }
602
816
  if (options.compact) {
603
- const hasText = Boolean(node.label && node.label.trim().length > 0);
604
- const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
605
- return hasText || hasId || Boolean(node.hittable);
817
+ return hasMeaningfulText || hasMeaningfulId || Boolean(node.hittable);
818
+ }
819
+ if (isStructural || isVisual) {
820
+ if (node.hittable) return true;
821
+ if (hasMeaningfulText) return true;
822
+ if (hasMeaningfulId && descendantHittable) return true;
823
+ return descendantHittable;
606
824
  }
607
825
  return true;
608
826
  }
609
827
 
828
+ function isCollectionContainerType(type: string | null): boolean {
829
+ if (!type) return false;
830
+ const normalized = normalizeAndroidType(type);
831
+ return (
832
+ normalized.includes('recyclerview') ||
833
+ normalized.includes('listview') ||
834
+ normalized.includes('gridview')
835
+ );
836
+ }
837
+
838
+ function normalizeAndroidType(type: string | null): string {
839
+ if (!type) return '';
840
+ return type.toLowerCase();
841
+ }
842
+
843
+ function isStructuralAndroidType(type: string): boolean {
844
+ const short = type.split('.').pop() ?? type;
845
+ return (
846
+ short.includes('layout') ||
847
+ short === 'viewgroup' ||
848
+ short === 'view'
849
+ );
850
+ }
851
+
852
+ function isGenericAndroidId(value: string): boolean {
853
+ const trimmed = value.trim();
854
+ if (!trimmed) return false;
855
+ return /^[\w.]+:id\/[\w.-]+$/i.test(trimmed);
856
+ }
857
+
610
858
  function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
611
859
  const query = scope.toLowerCase();
612
860
  const stack: AndroidNode[] = [...root.children];
@@ -20,6 +20,7 @@ export type RunnerCommand = {
20
20
  | 'home'
21
21
  | 'appSwitcher'
22
22
  | 'alert'
23
+ | 'pinch'
23
24
  | 'shutdown';
24
25
  appBundleId?: string;
25
26
  text?: string;
@@ -27,11 +28,13 @@ export type RunnerCommand = {
27
28
  x?: number;
28
29
  y?: number;
29
30
  direction?: 'up' | 'down' | 'left' | 'right';
31
+ scale?: number;
30
32
  interactiveOnly?: boolean;
31
33
  compact?: boolean;
32
34
  depth?: number;
33
35
  scope?: string;
34
36
  raw?: boolean;
37
+ clearFirst?: boolean;
35
38
  };
36
39
 
37
40
  export type RunnerSession = {
@@ -173,10 +176,9 @@ async function ensureRunnerSession(
173
176
  await ensureBooted(device.id);
174
177
  const xctestrun = await ensureXctestrun(device.id, options);
175
178
  const port = await getFreePort();
176
- const runnerTimeout = process.env.AGENT_DEVICE_RUNNER_TIMEOUT ?? '0';
177
179
  const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
178
180
  xctestrun,
179
- { AGENT_DEVICE_RUNNER_PORT: String(port), AGENT_DEVICE_RUNNER_TIMEOUT: runnerTimeout },
181
+ { AGENT_DEVICE_RUNNER_PORT: String(port) },
180
182
  `session-${device.id}-${port}`,
181
183
  );
182
184
  const testPromise = runCmdStreaming(
@@ -187,6 +189,8 @@ async function ensureRunnerSession(
187
189
  'AgentDeviceRunnerUITests/RunnerTests/testCommand',
188
190
  '-parallel-testing-enabled',
189
191
  'NO',
192
+ '-test-timeouts-enabled',
193
+ 'NO',
190
194
  '-maximum-concurrent-test-simulator-destinations',
191
195
  '1',
192
196
  '-xctestrun',
@@ -202,7 +206,7 @@ async function ensureRunnerSession(
202
206
  logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
203
207
  },
204
208
  allowFailure: true,
205
- env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port), AGENT_DEVICE_RUNNER_TIMEOUT: runnerTimeout },
209
+ env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
206
210
  },
207
211
  );
208
212
 
package/src/utils/args.ts CHANGED
@@ -20,6 +20,7 @@ export type ParsedArgs = {
20
20
  snapshotBackend?: 'ax' | 'xctest';
21
21
  appsFilter?: 'launchable' | 'user-installed' | 'all';
22
22
  appsMetadata?: boolean;
23
+ activity?: string;
23
24
  noRecord?: boolean;
24
25
  recordJson?: boolean;
25
26
  help: boolean;
@@ -125,6 +126,9 @@ export function parseArgs(argv: string[]): ParsedArgs {
125
126
  case '--session':
126
127
  flags.session = value;
127
128
  break;
129
+ case '--activity':
130
+ flags.activity = value;
131
+ break;
128
132
  default:
129
133
  throw new AppError('INVALID_ARGS', `Unknown flag: ${key}`);
130
134
  }
@@ -208,6 +212,7 @@ Flags:
208
212
  --device <name> Device name to target
209
213
  --udid <udid> iOS device UDID
210
214
  --serial <serial> Android device serial
215
+ --activity <component> Android activity to launch (package/Activity)
211
216
  --out <path> Output path for screenshots
212
217
  --session <name> Named session
213
218
  --verbose Stream daemon/runner logs
@@ -28,7 +28,7 @@ import {
28
28
  } from '../platforms/ios/index.ts';
29
29
 
30
30
  export type Interactor = {
31
- open(app: string): Promise<void>;
31
+ open(app: string, options?: { activity?: string }): Promise<void>;
32
32
  openDevice(): Promise<void>;
33
33
  close(app: string): Promise<void>;
34
34
  tap(x: number, y: number): Promise<void>;
@@ -45,7 +45,7 @@ export function getInteractor(device: DeviceInfo): Interactor {
45
45
  switch (device.platform) {
46
46
  case 'android':
47
47
  return {
48
- open: (app) => openAndroidApp(device, app),
48
+ open: (app, options) => openAndroidApp(device, app, options?.activity),
49
49
  openDevice: () => openAndroidDevice(device),
50
50
  close: (app) => closeAndroidApp(device, app),
51
51
  tap: (x, y) => pressAndroid(device, x, y),
@@ -28,9 +28,10 @@ type SnapshotNode = {
28
28
 
29
29
  export function formatSnapshotText(
30
30
  data: Record<string, unknown>,
31
- options: { raw?: boolean } = {},
31
+ options: { raw?: boolean; flatten?: boolean } = {},
32
32
  ): string {
33
- const nodes = (data.nodes ?? []) as SnapshotNode[];
33
+ const rawNodes = data.nodes;
34
+ const nodes = Array.isArray(rawNodes) ? (rawNodes as SnapshotNode[]) : [];
34
35
  const truncated = Boolean(data.truncated);
35
36
  const appName = typeof data.appName === 'string' ? data.appName : undefined;
36
37
  const appBundleId = typeof data.appBundleId === 'string' ? data.appBundleId : undefined;
@@ -39,13 +40,17 @@ export function formatSnapshotText(
39
40
  if (appBundleId) meta.push(`App: ${appBundleId}`);
40
41
  const header = `Snapshot: ${nodes.length} nodes${truncated ? ' (truncated)' : ''}`;
41
42
  const prefix = meta.length > 0 ? `${meta.join('\n')}\n` : '';
42
- if (!Array.isArray(nodes) || nodes.length === 0) {
43
+ if (nodes.length === 0) {
43
44
  return `${prefix}${header}\n`;
44
45
  }
45
46
  if (options.raw) {
46
47
  const rawLines = nodes.map((node) => JSON.stringify(node));
47
48
  return `${prefix}${header}\n${rawLines.join('\n')}\n`;
48
49
  }
50
+ if (options.flatten) {
51
+ const flatLines = nodes.map((node) => formatSnapshotLine(node, 0, false));
52
+ return `${prefix}${header}\n${flatLines.join('\n')}\n`;
53
+ }
49
54
  const hiddenGroupDepths: number[] = [];
50
55
  const lines: string[] = [];
51
56
  for (const node of nodes) {
@@ -62,28 +67,69 @@ export function formatSnapshotText(
62
67
  const adjustedDepth = isHiddenGroup
63
68
  ? depth
64
69
  : Math.max(0, depth - hiddenGroupDepths.length);
65
- const indent = ' '.repeat(adjustedDepth);
66
- const ref = node.ref ? `@${node.ref}` : '';
67
- const flags = [
68
- node.enabled === false ? 'disabled' : null,
69
- ]
70
- .filter(Boolean)
71
- .join(', ');
72
- const flagText = flags ? ` [${flags}]` : '';
73
- const textPart = label ? ` "${label}"` : '';
74
- if (isHiddenGroup) {
75
- lines.push(`${indent}${ref} [${type}]${flagText}`.trimEnd());
76
- continue;
77
- }
78
- lines.push(`${indent}${ref} [${type}]${textPart}${flagText}`.trimEnd());
70
+ lines.push(formatSnapshotLine(node, adjustedDepth, isHiddenGroup));
79
71
  }
80
72
  return `${prefix}${header}\n${lines.join('\n')}\n`;
81
73
  }
82
74
 
75
+ function formatSnapshotLine(node: SnapshotNode, depth: number, hiddenGroup: boolean): string {
76
+ const type = formatRole(node.type ?? 'Element');
77
+ const label = displayLabel(node, type);
78
+ const indent = ' '.repeat(depth);
79
+ const ref = node.ref ? `@${node.ref}` : '';
80
+ const flags = [node.enabled === false ? 'disabled' : null].filter(Boolean).join(', ');
81
+ const flagText = flags ? ` [${flags}]` : '';
82
+ const textPart = label ? ` "${label}"` : '';
83
+ if (hiddenGroup) {
84
+ return `${indent}${ref} [${type}]${flagText}`.trimEnd();
85
+ }
86
+ return `${indent}${ref} [${type}]${textPart}${flagText}`.trimEnd();
87
+ }
88
+
89
+ function displayLabel(node: SnapshotNode, type: string): string {
90
+ const label = node.label?.trim();
91
+ const value = node.value?.trim();
92
+ if (isEditableRole(type)) {
93
+ // For inputs, prefer current value over placeholder/accessibility label.
94
+ if (value) return value;
95
+ if (label) return label;
96
+ } else if (label) {
97
+ return label;
98
+ }
99
+ if (value) return value;
100
+ const identifier = node.identifier?.trim();
101
+ if (!identifier) return '';
102
+ if (isGenericResourceId(identifier) && (type === 'group' || type === 'image' || type === 'list' || type === 'collection')) {
103
+ return '';
104
+ }
105
+ return identifier;
106
+ }
107
+
108
+ function isEditableRole(type: string): boolean {
109
+ return type === 'text-field' || type === 'text-view' || type === 'search';
110
+ }
111
+
112
+ function isGenericResourceId(value: string): boolean {
113
+ return /^[\w.]+:id\/[\w.-]+$/i.test(value);
114
+ }
115
+
83
116
  function formatRole(type: string): string {
117
+ const raw = type;
84
118
  let normalized = type.replace(/XCUIElementType/gi, '').toLowerCase();
85
- if (normalized.startsWith("ax")) {
86
- normalized = normalized.replace(/^ax/, "");
119
+ const isAndroidClass =
120
+ raw.includes('.') &&
121
+ (raw.startsWith('android.') || raw.startsWith('androidx.') || raw.startsWith('com.'));
122
+ if (normalized.startsWith('ax')) {
123
+ normalized = normalized.replace(/^ax/, '');
124
+ }
125
+ if (normalized.includes('.')) {
126
+ normalized = normalized
127
+ .replace(/^android\.widget\./, '')
128
+ .replace(/^android\.view\./, '')
129
+ .replace(/^android\.webkit\./, '')
130
+ .replace(/^androidx\./, '')
131
+ .replace(/^com\.google\.android\./, '')
132
+ .replace(/^com\.android\./, '');
87
133
  }
88
134
  switch (normalized) {
89
135
  case 'application':
@@ -93,24 +139,40 @@ function formatRole(type: string): string {
93
139
  case 'tabbar':
94
140
  return 'tab-bar';
95
141
  case 'button':
142
+ case 'imagebutton':
96
143
  return 'button';
97
144
  case 'link':
98
145
  return 'link';
99
146
  case 'cell':
100
147
  return 'cell';
101
148
  case 'statictext':
149
+ case 'checkedtextview':
102
150
  return 'text';
103
151
  case 'textfield':
152
+ case 'edittext':
104
153
  return 'text-field';
105
154
  case 'textview':
155
+ return isAndroidClass ? 'text' : 'text-view';
156
+ case 'textarea':
106
157
  return 'text-view';
107
158
  case 'switch':
108
159
  return 'switch';
109
160
  case 'slider':
110
161
  return 'slider';
111
162
  case 'image':
163
+ case 'imageview':
112
164
  return 'image';
113
- case 'table':
165
+ case 'webview':
166
+ return 'webview';
167
+ case 'framelayout':
168
+ case 'linearlayout':
169
+ case 'relativelayout':
170
+ case 'constraintlayout':
171
+ case 'viewgroup':
172
+ case 'view':
173
+ return 'group';
174
+ case 'listview':
175
+ case 'recyclerview':
114
176
  return 'list';
115
177
  case 'collectionview':
116
178
  return 'collection';
@@ -122,12 +184,6 @@ function formatRole(type: string): string {
122
184
  return 'group';
123
185
  case 'window':
124
186
  return 'window';
125
- case 'statictext':
126
- return 'text';
127
- case 'textfield':
128
- return 'text-field';
129
- case 'textarea':
130
- return 'text-view';
131
187
  case 'checkbox':
132
188
  return 'checkbox';
133
189
  case 'radio':
@@ -137,6 +193,8 @@ function formatRole(type: string): string {
137
193
  case 'toolbar':
138
194
  return 'toolbar';
139
195
  case 'scrollarea':
196
+ case 'scrollview':
197
+ case 'nestedscrollview':
140
198
  return 'scroll-area';
141
199
  case 'table':
142
200
  return 'table';