Skip to content

Commit df5476a

Browse files
committed
feat: normalize Android snapshots
1 parent 6d2d157 commit df5476a

3 files changed

Lines changed: 167 additions & 35 deletions

File tree

src/cli.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export async function runCli(argv: string[]): Promise<void> {
5454
process.stdout.write(
5555
formatSnapshotText((response.data ?? {}) as Record<string, unknown>, {
5656
raw: flags.snapshotRaw,
57+
flatten: flags.snapshotInteractiveOnly,
5758
}),
5859
);
5960
if (logTailStopper) logTailStopper();

src/platforms/android/index.ts

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -493,14 +493,42 @@ function parseUiHierarchy(
493493
const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
494494
const roots = scopedRoot ? [scopedRoot] : tree.children;
495495

496-
const walk = (node: AndroidNode, depth: number, parentIndex?: number) => {
496+
const interactiveDescendantMemo = new Map<AndroidNode, boolean>();
497+
const hasInteractiveDescendant = (node: AndroidNode): boolean => {
498+
const cached = interactiveDescendantMemo.get(node);
499+
if (cached !== undefined) return cached;
500+
for (const child of node.children) {
501+
if (child.hittable || hasInteractiveDescendant(child)) {
502+
interactiveDescendantMemo.set(node, true);
503+
return true;
504+
}
505+
}
506+
interactiveDescendantMemo.set(node, false);
507+
return false;
508+
};
509+
510+
const walk = (
511+
node: AndroidNode,
512+
depth: number,
513+
parentIndex?: number,
514+
ancestorHittable: boolean = false,
515+
ancestorCollection: boolean = false,
516+
) => {
497517
if (nodes.length >= maxNodes) {
498518
truncated = true;
499519
return;
500520
}
501521
if (depth > maxDepth) return;
502522

503-
const include = options.raw ? true : shouldIncludeAndroidNode(node, options);
523+
const include = options.raw
524+
? true
525+
: shouldIncludeAndroidNode(
526+
node,
527+
options,
528+
ancestorHittable,
529+
hasInteractiveDescendant(node),
530+
ancestorCollection,
531+
);
504532
let currentIndex = parentIndex;
505533
if (include) {
506534
currentIndex = nodes.length;
@@ -517,14 +545,16 @@ function parseUiHierarchy(
517545
parentIndex,
518546
});
519547
}
548+
const nextAncestorHittable = ancestorHittable || Boolean(node.hittable);
549+
const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type);
520550
for (const child of node.children) {
521-
walk(child, depth + 1, currentIndex);
551+
walk(child, depth + 1, currentIndex, nextAncestorHittable, nextAncestorCollection);
522552
if (truncated) return;
523553
}
524554
};
525555

526556
for (const root of roots) {
527-
walk(root, 0, undefined);
557+
walk(root, 0, undefined, false, false);
528558
if (truncated) break;
529559
}
530560

@@ -630,18 +660,71 @@ function parseUiHierarchyTree(xml: string): AndroidNode {
630660
return root;
631661
}
632662

633-
function shouldIncludeAndroidNode(node: AndroidNode, options: SnapshotOptions): boolean {
663+
function shouldIncludeAndroidNode(
664+
node: AndroidNode,
665+
options: SnapshotOptions,
666+
ancestorHittable: boolean,
667+
descendantHittable: boolean,
668+
ancestorCollection: boolean,
669+
): boolean {
670+
const type = normalizeAndroidType(node.type);
671+
const hasText = Boolean(node.label && node.label.trim().length > 0);
672+
const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
673+
const hasMeaningfulText = hasText && !isGenericAndroidId(node.label ?? '');
674+
const hasMeaningfulId = hasId && !isGenericAndroidId(node.identifier ?? '');
675+
const isStructural = isStructuralAndroidType(type);
676+
const isVisual = type === 'imageview' || type === 'imagebutton';
634677
if (options.interactiveOnly) {
635-
return Boolean(node.hittable);
678+
if (node.hittable) return true;
679+
// Keep text proxies for tappable rows while dropping structural noise.
680+
const proxyCandidate = hasMeaningfulText || hasMeaningfulId;
681+
if (!proxyCandidate) return false;
682+
if (isVisual) return false;
683+
if (isStructural && !ancestorCollection) return false;
684+
return ancestorHittable || descendantHittable || ancestorCollection;
636685
}
637686
if (options.compact) {
638-
const hasText = Boolean(node.label && node.label.trim().length > 0);
639-
const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
640-
return hasText || hasId || Boolean(node.hittable);
687+
return hasMeaningfulText || hasMeaningfulId || Boolean(node.hittable);
688+
}
689+
if (isStructural || isVisual) {
690+
if (node.hittable) return true;
691+
if (hasMeaningfulText) return true;
692+
if (hasMeaningfulId && descendantHittable) return true;
693+
return descendantHittable;
641694
}
642695
return true;
643696
}
644697

698+
function isCollectionContainerType(type: string | null): boolean {
699+
if (!type) return false;
700+
const normalized = normalizeAndroidType(type);
701+
return (
702+
normalized.includes('recyclerview') ||
703+
normalized.includes('listview') ||
704+
normalized.includes('gridview')
705+
);
706+
}
707+
708+
function normalizeAndroidType(type: string | null): string {
709+
if (!type) return '';
710+
return type.toLowerCase();
711+
}
712+
713+
function isStructuralAndroidType(type: string): boolean {
714+
const short = type.split('.').pop() ?? type;
715+
return (
716+
short.includes('layout') ||
717+
short === 'viewgroup' ||
718+
short === 'view'
719+
);
720+
}
721+
722+
function isGenericAndroidId(value: string): boolean {
723+
const trimmed = value.trim();
724+
if (!trimmed) return false;
725+
return /^[\w.]+:id\/[\w.-]+$/i.test(trimmed);
726+
}
727+
645728
function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
646729
const query = scope.toLowerCase();
647730
const stack: AndroidNode[] = [...root.children];

src/utils/output.ts

Lines changed: 74 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ type SnapshotNode = {
2828

2929
export function formatSnapshotText(
3030
data: Record<string, unknown>,
31-
options: { raw?: boolean } = {},
31+
options: { raw?: boolean; flatten?: boolean } = {},
3232
): string {
33-
const nodes = (data.nodes ?? []) as SnapshotNode[];
33+
const rawNodes = data.nodes;
34+
const nodes = Array.isArray(rawNodes) ? (rawNodes as SnapshotNode[]) : [];
3435
const truncated = Boolean(data.truncated);
3536
const appName = typeof data.appName === 'string' ? data.appName : undefined;
3637
const appBundleId = typeof data.appBundleId === 'string' ? data.appBundleId : undefined;
@@ -39,13 +40,17 @@ export function formatSnapshotText(
3940
if (appBundleId) meta.push(`App: ${appBundleId}`);
4041
const header = `Snapshot: ${nodes.length} nodes${truncated ? ' (truncated)' : ''}`;
4142
const prefix = meta.length > 0 ? `${meta.join('\n')}\n` : '';
42-
if (!Array.isArray(nodes) || nodes.length === 0) {
43+
if (nodes.length === 0) {
4344
return `${prefix}${header}\n`;
4445
}
4546
if (options.raw) {
4647
const rawLines = nodes.map((node) => JSON.stringify(node));
4748
return `${prefix}${header}\n${rawLines.join('\n')}\n`;
4849
}
50+
if (options.flatten) {
51+
const flatLines = nodes.map((node) => formatSnapshotLine(node, 0, false));
52+
return `${prefix}${header}\n${flatLines.join('\n')}\n`;
53+
}
4954
const hiddenGroupDepths: number[] = [];
5055
const lines: string[] = [];
5156
for (const node of nodes) {
@@ -62,28 +67,59 @@ export function formatSnapshotText(
6267
const adjustedDepth = isHiddenGroup
6368
? depth
6469
: 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));
7971
}
8072
return `${prefix}${header}\n${lines.join('\n')}\n`;
8173
}
8274

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+
if (label) return label;
92+
const value = node.value?.trim();
93+
if (value) return value;
94+
const identifier = node.identifier?.trim();
95+
if (!identifier) return '';
96+
if (isGenericResourceId(identifier) && (type === 'group' || type === 'image' || type === 'list' || type === 'collection')) {
97+
return '';
98+
}
99+
return identifier;
100+
}
101+
102+
function isGenericResourceId(value: string): boolean {
103+
return /^[\w.]+:id\/[\w.-]+$/i.test(value);
104+
}
105+
83106
function formatRole(type: string): string {
107+
const raw = type;
84108
let normalized = type.replace(/XCUIElementType/gi, '').toLowerCase();
85-
if (normalized.startsWith("ax")) {
86-
normalized = normalized.replace(/^ax/, "");
109+
const isAndroidClass =
110+
raw.includes('.') &&
111+
(raw.startsWith('android.') || raw.startsWith('androidx.') || raw.startsWith('com.'));
112+
if (normalized.startsWith('ax')) {
113+
normalized = normalized.replace(/^ax/, '');
114+
}
115+
if (normalized.includes('.')) {
116+
normalized = normalized
117+
.replace(/^android\.widget\./, '')
118+
.replace(/^android\.view\./, '')
119+
.replace(/^android\.webkit\./, '')
120+
.replace(/^androidx\./, '')
121+
.replace(/^com\.google\.android\./, '')
122+
.replace(/^com\.android\./, '');
87123
}
88124
switch (normalized) {
89125
case 'application':
@@ -93,24 +129,40 @@ function formatRole(type: string): string {
93129
case 'tabbar':
94130
return 'tab-bar';
95131
case 'button':
132+
case 'imagebutton':
96133
return 'button';
97134
case 'link':
98135
return 'link';
99136
case 'cell':
100137
return 'cell';
101138
case 'statictext':
139+
case 'checkedtextview':
102140
return 'text';
103141
case 'textfield':
142+
case 'edittext':
104143
return 'text-field';
105144
case 'textview':
145+
return isAndroidClass ? 'text' : 'text-view';
146+
case 'textarea':
106147
return 'text-view';
107148
case 'switch':
108149
return 'switch';
109150
case 'slider':
110151
return 'slider';
111152
case 'image':
153+
case 'imageview':
112154
return 'image';
113-
case 'table':
155+
case 'webview':
156+
return 'webview';
157+
case 'framelayout':
158+
case 'linearlayout':
159+
case 'relativelayout':
160+
case 'constraintlayout':
161+
case 'viewgroup':
162+
case 'view':
163+
return 'group';
164+
case 'listview':
165+
case 'recyclerview':
114166
return 'list';
115167
case 'collectionview':
116168
return 'collection';
@@ -122,12 +174,6 @@ function formatRole(type: string): string {
122174
return 'group';
123175
case 'window':
124176
return 'window';
125-
case 'statictext':
126-
return 'text';
127-
case 'textfield':
128-
return 'text-field';
129-
case 'textarea':
130-
return 'text-view';
131177
case 'checkbox':
132178
return 'checkbox';
133179
case 'radio':
@@ -137,6 +183,8 @@ function formatRole(type: string): string {
137183
case 'toolbar':
138184
return 'toolbar';
139185
case 'scrollarea':
186+
case 'scrollview':
187+
case 'nestedscrollview':
140188
return 'scroll-area';
141189
case 'table':
142190
return 'table';

0 commit comments

Comments
 (0)