agent-device 0.2.6 → 0.3.1
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.
- package/README.md +15 -0
- package/dist/bin/axsnapshot +0 -0
- package/dist/src/274.js +1 -0
- package/dist/src/bin.js +29 -27
- package/dist/src/daemon.js +9 -6
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +151 -2
- package/package.json +2 -2
- package/src/cli.ts +6 -0
- package/src/daemon-client.ts +1 -24
- package/src/daemon.ts +1 -24
- package/src/platforms/__tests__/boot-diagnostics.test.ts +30 -0
- package/src/platforms/android/__tests__/index.test.ts +74 -0
- package/src/platforms/android/devices.ts +133 -41
- package/src/platforms/android/index.ts +47 -293
- package/src/platforms/android/ui-hierarchy.ts +312 -0
- package/src/platforms/boot-diagnostics.ts +67 -0
- package/src/platforms/ios/index.ts +94 -2
- package/src/platforms/ios/runner-client.ts +115 -55
- package/src/utils/__tests__/retry.test.ts +27 -0
- package/src/utils/args.ts +7 -1
- package/src/utils/retry.ts +73 -13
- package/src/utils/version.ts +26 -0
- package/dist/src/861.js +0 -1
|
@@ -3,8 +3,9 @@ import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
|
3
3
|
import { withRetry } from '../../utils/retry.ts';
|
|
4
4
|
import { AppError } from '../../utils/errors.ts';
|
|
5
5
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
6
|
-
import type { RawSnapshotNode,
|
|
6
|
+
import type { RawSnapshotNode, SnapshotOptions } from '../../utils/snapshot.ts';
|
|
7
7
|
import { waitForAndroidBoot } from './devices.ts';
|
|
8
|
+
import { findBounds, parseBounds, parseUiHierarchy, readNodeAttributes } from './ui-hierarchy.ts';
|
|
8
9
|
|
|
9
10
|
const ALIASES: Record<string, { type: 'intent' | 'package'; value: string }> = {
|
|
10
11
|
settings: { type: 'intent', value: 'android.settings.SETTINGS' },
|
|
@@ -449,12 +450,52 @@ async function dumpUiHierarchy(device: DeviceInfo): Promise<string> {
|
|
|
449
450
|
}
|
|
450
451
|
|
|
451
452
|
async function dumpUiHierarchyOnce(device: DeviceInfo): Promise<string> {
|
|
452
|
-
|
|
453
|
+
// Preferred: stream XML directly to stdout, avoiding file I/O race conditions.
|
|
454
|
+
const streamed = await runCmd(
|
|
453
455
|
'adb',
|
|
454
|
-
adbArgs(device, ['
|
|
456
|
+
adbArgs(device, ['exec-out', 'uiautomator', 'dump', '/dev/tty']),
|
|
457
|
+
{ allowFailure: true },
|
|
455
458
|
);
|
|
456
|
-
|
|
457
|
-
|
|
459
|
+
if (streamed.exitCode === 0) {
|
|
460
|
+
const fromStream = extractUiDumpXml(streamed.stdout, streamed.stderr);
|
|
461
|
+
if (fromStream) return fromStream;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Fallback: dump to file and read back.
|
|
465
|
+
// If `cat` fails with "no such file", the outer withRetry (via isRetryableAdbError) handles it.
|
|
466
|
+
const dumpPath = '/sdcard/window_dump.xml';
|
|
467
|
+
const dumpResult = await runCmd(
|
|
468
|
+
'adb',
|
|
469
|
+
adbArgs(device, ['shell', 'uiautomator', 'dump', dumpPath]),
|
|
470
|
+
);
|
|
471
|
+
const actualPath = resolveDumpPath(dumpPath, dumpResult.stdout, dumpResult.stderr);
|
|
472
|
+
|
|
473
|
+
const result = await runCmd('adb', adbArgs(device, ['shell', 'cat', actualPath]));
|
|
474
|
+
const xml = extractUiDumpXml(result.stdout, result.stderr);
|
|
475
|
+
if (!xml) {
|
|
476
|
+
throw new AppError('COMMAND_FAILED', 'uiautomator dump did not return XML', {
|
|
477
|
+
stdout: result.stdout,
|
|
478
|
+
stderr: result.stderr,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return xml;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function resolveDumpPath(defaultPath: string, stdout: string, stderr: string): string {
|
|
485
|
+
const text = `${stdout}\n${stderr}`;
|
|
486
|
+
const match = /dumped to:\s*(\S+)/i.exec(text);
|
|
487
|
+
return match?.[1] ?? defaultPath;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function extractUiDumpXml(stdout: string, stderr: string): string | null {
|
|
491
|
+
const text = `${stdout}\n${stderr}`;
|
|
492
|
+
const start = text.indexOf('<?xml');
|
|
493
|
+
const hierarchyStart = start >= 0 ? start : text.indexOf('<hierarchy');
|
|
494
|
+
if (hierarchyStart < 0) return null;
|
|
495
|
+
const end = text.lastIndexOf('</hierarchy>');
|
|
496
|
+
if (end < 0 || end < hierarchyStart) return null;
|
|
497
|
+
const xml = text.slice(hierarchyStart, end + '</hierarchy>'.length).trim();
|
|
498
|
+
return xml.length > 0 ? xml : null;
|
|
458
499
|
}
|
|
459
500
|
|
|
460
501
|
function isRetryableAdbError(err: unknown): boolean {
|
|
@@ -467,6 +508,7 @@ function isRetryableAdbError(err: unknown): boolean {
|
|
|
467
508
|
if (stderr.includes('connection reset')) return true;
|
|
468
509
|
if (stderr.includes('broken pipe')) return true;
|
|
469
510
|
if (stderr.includes('timed out')) return true;
|
|
511
|
+
if (stderr.includes('no such file or directory')) return true;
|
|
470
512
|
return false;
|
|
471
513
|
}
|
|
472
514
|
|
|
@@ -582,291 +624,3 @@ async function sleep(ms: number): Promise<void> {
|
|
|
582
624
|
function clampCount(value: number, min: number, max: number): number {
|
|
583
625
|
return Math.max(min, Math.min(max, value));
|
|
584
626
|
}
|
|
585
|
-
|
|
586
|
-
function findBounds(xml: string, query: string): { x: number; y: number } | null {
|
|
587
|
-
const q = query.toLowerCase();
|
|
588
|
-
const nodeRegex = /<node[^>]+>/g;
|
|
589
|
-
let match = nodeRegex.exec(xml);
|
|
590
|
-
while (match) {
|
|
591
|
-
const node = match[0];
|
|
592
|
-
const textMatch = /text="([^"]*)"/.exec(node);
|
|
593
|
-
const descMatch = /content-desc="([^"]*)"/.exec(node);
|
|
594
|
-
const textVal = (textMatch?.[1] ?? '').toLowerCase();
|
|
595
|
-
const descVal = (descMatch?.[1] ?? '').toLowerCase();
|
|
596
|
-
if (textVal.includes(q) || descVal.includes(q)) {
|
|
597
|
-
const boundsMatch = /bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/.exec(node);
|
|
598
|
-
if (boundsMatch) {
|
|
599
|
-
const x1 = Number(boundsMatch[1]);
|
|
600
|
-
const y1 = Number(boundsMatch[2]);
|
|
601
|
-
const x2 = Number(boundsMatch[3]);
|
|
602
|
-
const y2 = Number(boundsMatch[4]);
|
|
603
|
-
return { x: Math.floor((x1 + x2) / 2), y: Math.floor((y1 + y2) / 2) };
|
|
604
|
-
}
|
|
605
|
-
return { x: 0, y: 0 };
|
|
606
|
-
}
|
|
607
|
-
match = nodeRegex.exec(xml);
|
|
608
|
-
}
|
|
609
|
-
return null;
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
function parseUiHierarchy(
|
|
613
|
-
xml: string,
|
|
614
|
-
maxNodes: number,
|
|
615
|
-
options: SnapshotOptions,
|
|
616
|
-
): { nodes: RawSnapshotNode[]; truncated?: boolean } {
|
|
617
|
-
const tree = parseUiHierarchyTree(xml);
|
|
618
|
-
const nodes: RawSnapshotNode[] = [];
|
|
619
|
-
let truncated = false;
|
|
620
|
-
const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
|
|
621
|
-
const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
|
|
622
|
-
const roots = scopedRoot ? [scopedRoot] : tree.children;
|
|
623
|
-
|
|
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
|
-
) => {
|
|
645
|
-
if (nodes.length >= maxNodes) {
|
|
646
|
-
truncated = true;
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
if (depth > maxDepth) return;
|
|
650
|
-
|
|
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;
|
|
661
|
-
if (include) {
|
|
662
|
-
currentIndex = nodes.length;
|
|
663
|
-
nodes.push({
|
|
664
|
-
index: currentIndex,
|
|
665
|
-
type: node.type ?? undefined,
|
|
666
|
-
label: node.label ?? undefined,
|
|
667
|
-
value: node.value ?? undefined,
|
|
668
|
-
identifier: node.identifier ?? undefined,
|
|
669
|
-
rect: node.rect,
|
|
670
|
-
enabled: node.enabled,
|
|
671
|
-
hittable: node.hittable,
|
|
672
|
-
depth,
|
|
673
|
-
parentIndex,
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
const nextAncestorHittable = ancestorHittable || Boolean(node.hittable);
|
|
677
|
-
const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type);
|
|
678
|
-
for (const child of node.children) {
|
|
679
|
-
walk(child, depth + 1, currentIndex, nextAncestorHittable, nextAncestorCollection);
|
|
680
|
-
if (truncated) return;
|
|
681
|
-
}
|
|
682
|
-
};
|
|
683
|
-
|
|
684
|
-
for (const root of roots) {
|
|
685
|
-
walk(root, 0, undefined, false, false);
|
|
686
|
-
if (truncated) break;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
return truncated ? { nodes, truncated } : { nodes };
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function readNodeAttributes(node: string): {
|
|
693
|
-
text: string | null;
|
|
694
|
-
desc: string | null;
|
|
695
|
-
resourceId: string | null;
|
|
696
|
-
className: string | null;
|
|
697
|
-
bounds: string | null;
|
|
698
|
-
clickable?: boolean;
|
|
699
|
-
enabled?: boolean;
|
|
700
|
-
focusable?: boolean;
|
|
701
|
-
focused?: boolean;
|
|
702
|
-
} {
|
|
703
|
-
const getAttr = (name: string): string | null => {
|
|
704
|
-
const regex = new RegExp(`${name}="([^"]*)"`);
|
|
705
|
-
const match = regex.exec(node);
|
|
706
|
-
return match ? match[1] : null;
|
|
707
|
-
};
|
|
708
|
-
const boolAttr = (name: string): boolean | undefined => {
|
|
709
|
-
const raw = getAttr(name);
|
|
710
|
-
if (raw === null) return undefined;
|
|
711
|
-
return raw === 'true';
|
|
712
|
-
};
|
|
713
|
-
return {
|
|
714
|
-
text: getAttr('text'),
|
|
715
|
-
desc: getAttr('content-desc'),
|
|
716
|
-
resourceId: getAttr('resource-id'),
|
|
717
|
-
className: getAttr('class'),
|
|
718
|
-
bounds: getAttr('bounds'),
|
|
719
|
-
clickable: boolAttr('clickable'),
|
|
720
|
-
enabled: boolAttr('enabled'),
|
|
721
|
-
focusable: boolAttr('focusable'),
|
|
722
|
-
focused: boolAttr('focused'),
|
|
723
|
-
};
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function parseBounds(bounds: string | null): Rect | undefined {
|
|
727
|
-
if (!bounds) return undefined;
|
|
728
|
-
const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
|
|
729
|
-
if (!match) return undefined;
|
|
730
|
-
const x1 = Number(match[1]);
|
|
731
|
-
const y1 = Number(match[2]);
|
|
732
|
-
const x2 = Number(match[3]);
|
|
733
|
-
const y2 = Number(match[4]);
|
|
734
|
-
return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
type AndroidNode = {
|
|
738
|
-
type: string | null;
|
|
739
|
-
label: string | null;
|
|
740
|
-
value: string | null;
|
|
741
|
-
identifier: string | null;
|
|
742
|
-
rect?: Rect;
|
|
743
|
-
enabled?: boolean;
|
|
744
|
-
hittable?: boolean;
|
|
745
|
-
depth: number;
|
|
746
|
-
parentIndex?: number;
|
|
747
|
-
children: AndroidNode[];
|
|
748
|
-
};
|
|
749
|
-
|
|
750
|
-
function parseUiHierarchyTree(xml: string): AndroidNode {
|
|
751
|
-
const root: AndroidNode = {
|
|
752
|
-
type: null,
|
|
753
|
-
label: null,
|
|
754
|
-
value: null,
|
|
755
|
-
identifier: null,
|
|
756
|
-
depth: -1,
|
|
757
|
-
children: [],
|
|
758
|
-
};
|
|
759
|
-
const stack: AndroidNode[] = [root];
|
|
760
|
-
const tokenRegex = /<node\b[^>]*>|<\/node>/g;
|
|
761
|
-
let match = tokenRegex.exec(xml);
|
|
762
|
-
while (match) {
|
|
763
|
-
const token = match[0];
|
|
764
|
-
if (token.startsWith('</node')) {
|
|
765
|
-
if (stack.length > 1) stack.pop();
|
|
766
|
-
match = tokenRegex.exec(xml);
|
|
767
|
-
continue;
|
|
768
|
-
}
|
|
769
|
-
const attrs = readNodeAttributes(token);
|
|
770
|
-
const rect = parseBounds(attrs.bounds);
|
|
771
|
-
const parent = stack[stack.length - 1];
|
|
772
|
-
const node: AndroidNode = {
|
|
773
|
-
type: attrs.className,
|
|
774
|
-
label: attrs.text || attrs.desc,
|
|
775
|
-
value: attrs.text,
|
|
776
|
-
identifier: attrs.resourceId,
|
|
777
|
-
rect,
|
|
778
|
-
enabled: attrs.enabled,
|
|
779
|
-
hittable: attrs.clickable ?? attrs.focusable,
|
|
780
|
-
depth: parent.depth + 1,
|
|
781
|
-
parentIndex: undefined,
|
|
782
|
-
children: [],
|
|
783
|
-
};
|
|
784
|
-
parent.children.push(node);
|
|
785
|
-
if (!token.endsWith('/>')) {
|
|
786
|
-
stack.push(node);
|
|
787
|
-
}
|
|
788
|
-
match = tokenRegex.exec(xml);
|
|
789
|
-
}
|
|
790
|
-
return root;
|
|
791
|
-
}
|
|
792
|
-
|
|
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';
|
|
807
|
-
if (options.interactiveOnly) {
|
|
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;
|
|
815
|
-
}
|
|
816
|
-
if (options.compact) {
|
|
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;
|
|
824
|
-
}
|
|
825
|
-
return true;
|
|
826
|
-
}
|
|
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
|
-
|
|
858
|
-
function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
|
|
859
|
-
const query = scope.toLowerCase();
|
|
860
|
-
const stack: AndroidNode[] = [...root.children];
|
|
861
|
-
while (stack.length > 0) {
|
|
862
|
-
const node = stack.shift() as AndroidNode;
|
|
863
|
-
const label = node.label?.toLowerCase() ?? '';
|
|
864
|
-
const value = node.value?.toLowerCase() ?? '';
|
|
865
|
-
const identifier = node.identifier?.toLowerCase() ?? '';
|
|
866
|
-
if (label.includes(query) || value.includes(query) || identifier.includes(query)) {
|
|
867
|
-
return node;
|
|
868
|
-
}
|
|
869
|
-
stack.push(...node.children);
|
|
870
|
-
}
|
|
871
|
-
return null;
|
|
872
|
-
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import type { RawSnapshotNode, Rect, SnapshotOptions } from '../../utils/snapshot.ts';
|
|
2
|
+
|
|
3
|
+
export function findBounds(xml: string, query: string): { x: number; y: number } | null {
|
|
4
|
+
const q = query.toLowerCase();
|
|
5
|
+
const nodeRegex = /<node[^>]+>/g;
|
|
6
|
+
let match = nodeRegex.exec(xml);
|
|
7
|
+
while (match) {
|
|
8
|
+
const node = match[0];
|
|
9
|
+
const attrs = parseXmlNodeAttributes(node);
|
|
10
|
+
const textVal = (readXmlAttr(attrs, 'text') ?? '').toLowerCase();
|
|
11
|
+
const descVal = (readXmlAttr(attrs, 'content-desc') ?? '').toLowerCase();
|
|
12
|
+
if (textVal.includes(q) || descVal.includes(q)) {
|
|
13
|
+
const rect = parseBounds(readXmlAttr(attrs, 'bounds'));
|
|
14
|
+
if (rect) {
|
|
15
|
+
return {
|
|
16
|
+
x: Math.floor(rect.x + rect.width / 2),
|
|
17
|
+
y: Math.floor(rect.y + rect.height / 2),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return { x: 0, y: 0 };
|
|
21
|
+
}
|
|
22
|
+
match = nodeRegex.exec(xml);
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseUiHierarchy(
|
|
28
|
+
xml: string,
|
|
29
|
+
maxNodes: number,
|
|
30
|
+
options: SnapshotOptions,
|
|
31
|
+
): { nodes: RawSnapshotNode[]; truncated?: boolean } {
|
|
32
|
+
const tree = parseUiHierarchyTree(xml);
|
|
33
|
+
const nodes: RawSnapshotNode[] = [];
|
|
34
|
+
let truncated = false;
|
|
35
|
+
const maxDepth = options.depth ?? Number.POSITIVE_INFINITY;
|
|
36
|
+
const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
|
|
37
|
+
const roots = scopedRoot ? [scopedRoot] : tree.children;
|
|
38
|
+
|
|
39
|
+
const interactiveDescendantMemo = new Map<AndroidNode, boolean>();
|
|
40
|
+
const hasInteractiveDescendant = (node: AndroidNode): boolean => {
|
|
41
|
+
const cached = interactiveDescendantMemo.get(node);
|
|
42
|
+
if (cached !== undefined) return cached;
|
|
43
|
+
for (const child of node.children) {
|
|
44
|
+
if (child.hittable || hasInteractiveDescendant(child)) {
|
|
45
|
+
interactiveDescendantMemo.set(node, true);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
interactiveDescendantMemo.set(node, false);
|
|
50
|
+
return false;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const walk = (
|
|
54
|
+
node: AndroidNode,
|
|
55
|
+
depth: number,
|
|
56
|
+
parentIndex?: number,
|
|
57
|
+
ancestorHittable: boolean = false,
|
|
58
|
+
ancestorCollection: boolean = false,
|
|
59
|
+
) => {
|
|
60
|
+
if (nodes.length >= maxNodes) {
|
|
61
|
+
truncated = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (depth > maxDepth) return;
|
|
65
|
+
|
|
66
|
+
const include = options.raw
|
|
67
|
+
? true
|
|
68
|
+
: shouldIncludeAndroidNode(
|
|
69
|
+
node,
|
|
70
|
+
options,
|
|
71
|
+
ancestorHittable,
|
|
72
|
+
hasInteractiveDescendant(node),
|
|
73
|
+
ancestorCollection,
|
|
74
|
+
);
|
|
75
|
+
let currentIndex = parentIndex;
|
|
76
|
+
if (include) {
|
|
77
|
+
currentIndex = nodes.length;
|
|
78
|
+
nodes.push({
|
|
79
|
+
index: currentIndex,
|
|
80
|
+
type: node.type ?? undefined,
|
|
81
|
+
label: node.label ?? undefined,
|
|
82
|
+
value: node.value ?? undefined,
|
|
83
|
+
identifier: node.identifier ?? undefined,
|
|
84
|
+
rect: node.rect,
|
|
85
|
+
enabled: node.enabled,
|
|
86
|
+
hittable: node.hittable,
|
|
87
|
+
depth,
|
|
88
|
+
parentIndex,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const nextAncestorHittable = ancestorHittable || Boolean(node.hittable);
|
|
92
|
+
const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type);
|
|
93
|
+
for (const child of node.children) {
|
|
94
|
+
walk(child, depth + 1, currentIndex, nextAncestorHittable, nextAncestorCollection);
|
|
95
|
+
if (truncated) return;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
for (const root of roots) {
|
|
100
|
+
walk(root, 0, undefined, false, false);
|
|
101
|
+
if (truncated) break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return truncated ? { nodes, truncated } : { nodes };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function readNodeAttributes(node: string): {
|
|
108
|
+
text: string | null;
|
|
109
|
+
desc: string | null;
|
|
110
|
+
resourceId: string | null;
|
|
111
|
+
className: string | null;
|
|
112
|
+
bounds: string | null;
|
|
113
|
+
clickable?: boolean;
|
|
114
|
+
enabled?: boolean;
|
|
115
|
+
focusable?: boolean;
|
|
116
|
+
focused?: boolean;
|
|
117
|
+
} {
|
|
118
|
+
const attrs = parseXmlNodeAttributes(node);
|
|
119
|
+
const getAttr = (name: string): string | null => readXmlAttr(attrs, name);
|
|
120
|
+
const boolAttr = (name: string): boolean | undefined => {
|
|
121
|
+
const raw = getAttr(name);
|
|
122
|
+
if (raw === null) return undefined;
|
|
123
|
+
return raw === 'true';
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
text: getAttr('text'),
|
|
127
|
+
desc: getAttr('content-desc'),
|
|
128
|
+
resourceId: getAttr('resource-id'),
|
|
129
|
+
className: getAttr('class'),
|
|
130
|
+
bounds: getAttr('bounds'),
|
|
131
|
+
clickable: boolAttr('clickable'),
|
|
132
|
+
enabled: boolAttr('enabled'),
|
|
133
|
+
focusable: boolAttr('focusable'),
|
|
134
|
+
focused: boolAttr('focused'),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function parseXmlNodeAttributes(node: string): Map<string, string> {
|
|
139
|
+
const attrs = new Map<string, string>();
|
|
140
|
+
const start = node.indexOf(' ');
|
|
141
|
+
const end = node.lastIndexOf('>');
|
|
142
|
+
if (start < 0 || end <= start) return attrs;
|
|
143
|
+
|
|
144
|
+
const attrRegex = /([^\s=/>]+)\s*=\s*(["'])([\s\S]*?)\2/y;
|
|
145
|
+
let cursor = start;
|
|
146
|
+
while (cursor < end) {
|
|
147
|
+
while (cursor < end) {
|
|
148
|
+
const char = node[cursor];
|
|
149
|
+
if (char !== ' ' && char !== '\n' && char !== '\r' && char !== '\t') break;
|
|
150
|
+
cursor += 1;
|
|
151
|
+
}
|
|
152
|
+
if (cursor >= end) break;
|
|
153
|
+
const char = node[cursor];
|
|
154
|
+
if (char === '/' || char === '>') break;
|
|
155
|
+
|
|
156
|
+
attrRegex.lastIndex = cursor;
|
|
157
|
+
const match = attrRegex.exec(node);
|
|
158
|
+
if (!match) break;
|
|
159
|
+
attrs.set(match[1], match[3]);
|
|
160
|
+
cursor = attrRegex.lastIndex;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return attrs;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function readXmlAttr(attrs: Map<string, string>, name: string): string | null {
|
|
167
|
+
return attrs.get(name) ?? null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function parseBounds(bounds: string | null): Rect | undefined {
|
|
171
|
+
if (!bounds) return undefined;
|
|
172
|
+
const match = /\[(\d+),(\d+)\]\[(\d+),(\d+)\]/.exec(bounds);
|
|
173
|
+
if (!match) return undefined;
|
|
174
|
+
const x1 = Number(match[1]);
|
|
175
|
+
const y1 = Number(match[2]);
|
|
176
|
+
const x2 = Number(match[3]);
|
|
177
|
+
const y2 = Number(match[4]);
|
|
178
|
+
return { x: x1, y: y1, width: Math.max(0, x2 - x1), height: Math.max(0, y2 - y1) };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type AndroidNode = {
|
|
182
|
+
type: string | null;
|
|
183
|
+
label: string | null;
|
|
184
|
+
value: string | null;
|
|
185
|
+
identifier: string | null;
|
|
186
|
+
rect?: Rect;
|
|
187
|
+
enabled?: boolean;
|
|
188
|
+
hittable?: boolean;
|
|
189
|
+
depth: number;
|
|
190
|
+
parentIndex?: number;
|
|
191
|
+
children: AndroidNode[];
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
function parseUiHierarchyTree(xml: string): AndroidNode {
|
|
195
|
+
const root: AndroidNode = {
|
|
196
|
+
type: null,
|
|
197
|
+
label: null,
|
|
198
|
+
value: null,
|
|
199
|
+
identifier: null,
|
|
200
|
+
depth: -1,
|
|
201
|
+
children: [],
|
|
202
|
+
};
|
|
203
|
+
const stack: AndroidNode[] = [root];
|
|
204
|
+
const tokenRegex = /<node\b[^>]*>|<\/node>/g;
|
|
205
|
+
let match = tokenRegex.exec(xml);
|
|
206
|
+
while (match) {
|
|
207
|
+
const token = match[0];
|
|
208
|
+
if (token.startsWith('</node')) {
|
|
209
|
+
if (stack.length > 1) stack.pop();
|
|
210
|
+
match = tokenRegex.exec(xml);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const attrs = readNodeAttributes(token);
|
|
214
|
+
const rect = parseBounds(attrs.bounds);
|
|
215
|
+
const parent = stack[stack.length - 1];
|
|
216
|
+
const node: AndroidNode = {
|
|
217
|
+
type: attrs.className,
|
|
218
|
+
label: attrs.text || attrs.desc,
|
|
219
|
+
value: attrs.text,
|
|
220
|
+
identifier: attrs.resourceId,
|
|
221
|
+
rect,
|
|
222
|
+
enabled: attrs.enabled,
|
|
223
|
+
hittable: attrs.clickable ?? attrs.focusable,
|
|
224
|
+
depth: parent.depth + 1,
|
|
225
|
+
parentIndex: undefined,
|
|
226
|
+
children: [],
|
|
227
|
+
};
|
|
228
|
+
parent.children.push(node);
|
|
229
|
+
if (!token.endsWith('/>')) {
|
|
230
|
+
stack.push(node);
|
|
231
|
+
}
|
|
232
|
+
match = tokenRegex.exec(xml);
|
|
233
|
+
}
|
|
234
|
+
return root;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function shouldIncludeAndroidNode(
|
|
238
|
+
node: AndroidNode,
|
|
239
|
+
options: SnapshotOptions,
|
|
240
|
+
ancestorHittable: boolean,
|
|
241
|
+
descendantHittable: boolean,
|
|
242
|
+
ancestorCollection: boolean,
|
|
243
|
+
): boolean {
|
|
244
|
+
const type = normalizeAndroidType(node.type);
|
|
245
|
+
const hasText = Boolean(node.label && node.label.trim().length > 0);
|
|
246
|
+
const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
|
|
247
|
+
const hasMeaningfulText = hasText && !isGenericAndroidId(node.label ?? '');
|
|
248
|
+
const hasMeaningfulId = hasId && !isGenericAndroidId(node.identifier ?? '');
|
|
249
|
+
const isStructural = isStructuralAndroidType(type);
|
|
250
|
+
const isVisual = type === 'imageview' || type === 'imagebutton';
|
|
251
|
+
if (options.interactiveOnly) {
|
|
252
|
+
if (node.hittable) return true;
|
|
253
|
+
// Keep text proxies for tappable rows while dropping structural noise.
|
|
254
|
+
const proxyCandidate = hasMeaningfulText || hasMeaningfulId;
|
|
255
|
+
if (!proxyCandidate) return false;
|
|
256
|
+
if (isVisual) return false;
|
|
257
|
+
if (isStructural && !ancestorCollection) return false;
|
|
258
|
+
return ancestorHittable || descendantHittable || ancestorCollection;
|
|
259
|
+
}
|
|
260
|
+
if (options.compact) {
|
|
261
|
+
return hasMeaningfulText || hasMeaningfulId || Boolean(node.hittable);
|
|
262
|
+
}
|
|
263
|
+
if (isStructural || isVisual) {
|
|
264
|
+
if (node.hittable) return true;
|
|
265
|
+
if (hasMeaningfulText) return true;
|
|
266
|
+
if (hasMeaningfulId && descendantHittable) return true;
|
|
267
|
+
return descendantHittable;
|
|
268
|
+
}
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function isCollectionContainerType(type: string | null): boolean {
|
|
273
|
+
if (!type) return false;
|
|
274
|
+
const normalized = normalizeAndroidType(type);
|
|
275
|
+
return (
|
|
276
|
+
normalized.includes('recyclerview') ||
|
|
277
|
+
normalized.includes('listview') ||
|
|
278
|
+
normalized.includes('gridview')
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function normalizeAndroidType(type: string | null): string {
|
|
283
|
+
if (!type) return '';
|
|
284
|
+
return type.toLowerCase();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function isStructuralAndroidType(type: string): boolean {
|
|
288
|
+
const short = type.split('.').pop() ?? type;
|
|
289
|
+
return short.includes('layout') || short === 'viewgroup' || short === 'view';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isGenericAndroidId(value: string): boolean {
|
|
293
|
+
const trimmed = value.trim();
|
|
294
|
+
if (!trimmed) return false;
|
|
295
|
+
return /^[\w.]+:id\/[\w.-]+$/i.test(trimmed);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
|
|
299
|
+
const query = scope.toLowerCase();
|
|
300
|
+
const stack: AndroidNode[] = [...root.children];
|
|
301
|
+
while (stack.length > 0) {
|
|
302
|
+
const node = stack.shift() as AndroidNode;
|
|
303
|
+
const label = node.label?.toLowerCase() ?? '';
|
|
304
|
+
const value = node.value?.toLowerCase() ?? '';
|
|
305
|
+
const identifier = node.identifier?.toLowerCase() ?? '';
|
|
306
|
+
if (label.includes(query) || value.includes(query) || identifier.includes(query)) {
|
|
307
|
+
return node;
|
|
308
|
+
}
|
|
309
|
+
stack.push(...node.children);
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|