Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export async function runCli(argv: string[]): Promise<void> {
process.stdout.write(
formatSnapshotText((response.data ?? {}) as Record<string, unknown>, {
raw: flags.snapshotRaw,
flatten: flags.snapshotInteractiveOnly,
}),
);
if (logTailStopper) logTailStopper();
Expand Down
101 changes: 92 additions & 9 deletions src/platforms/android/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,14 +493,42 @@ function parseUiHierarchy(
const scopedRoot = options.scope ? findScopeNode(tree, options.scope) : null;
const roots = scopedRoot ? [scopedRoot] : tree.children;

const walk = (node: AndroidNode, depth: number, parentIndex?: number) => {
const interactiveDescendantMemo = new Map<AndroidNode, boolean>();
const hasInteractiveDescendant = (node: AndroidNode): boolean => {
const cached = interactiveDescendantMemo.get(node);
if (cached !== undefined) return cached;
for (const child of node.children) {
if (child.hittable || hasInteractiveDescendant(child)) {
interactiveDescendantMemo.set(node, true);
return true;
}
}
interactiveDescendantMemo.set(node, false);
return false;
};

const walk = (
node: AndroidNode,
depth: number,
parentIndex?: number,
ancestorHittable: boolean = false,
ancestorCollection: boolean = false,
) => {
if (nodes.length >= maxNodes) {
truncated = true;
return;
}
if (depth > maxDepth) return;

const include = options.raw ? true : shouldIncludeAndroidNode(node, options);
const include = options.raw
? true
: shouldIncludeAndroidNode(
node,
options,
ancestorHittable,
hasInteractiveDescendant(node),
ancestorCollection,
);
let currentIndex = parentIndex;
if (include) {
currentIndex = nodes.length;
Expand All @@ -517,14 +545,16 @@ function parseUiHierarchy(
parentIndex,
});
}
const nextAncestorHittable = ancestorHittable || Boolean(node.hittable);
const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type);
for (const child of node.children) {
walk(child, depth + 1, currentIndex);
walk(child, depth + 1, currentIndex, nextAncestorHittable, nextAncestorCollection);
if (truncated) return;
}
};

for (const root of roots) {
walk(root, 0, undefined);
walk(root, 0, undefined, false, false);
if (truncated) break;
}

Expand Down Expand Up @@ -630,18 +660,71 @@ function parseUiHierarchyTree(xml: string): AndroidNode {
return root;
}

function shouldIncludeAndroidNode(node: AndroidNode, options: SnapshotOptions): boolean {
function shouldIncludeAndroidNode(
node: AndroidNode,
options: SnapshotOptions,
ancestorHittable: boolean,
descendantHittable: boolean,
ancestorCollection: boolean,
): boolean {
const type = normalizeAndroidType(node.type);
const hasText = Boolean(node.label && node.label.trim().length > 0);
const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
const hasMeaningfulText = hasText && !isGenericAndroidId(node.label ?? '');
const hasMeaningfulId = hasId && !isGenericAndroidId(node.identifier ?? '');
const isStructural = isStructuralAndroidType(type);
const isVisual = type === 'imageview' || type === 'imagebutton';
if (options.interactiveOnly) {
return Boolean(node.hittable);
if (node.hittable) return true;
// Keep text proxies for tappable rows while dropping structural noise.
const proxyCandidate = hasMeaningfulText || hasMeaningfulId;
if (!proxyCandidate) return false;
if (isVisual) return false;
if (isStructural && !ancestorCollection) return false;
return ancestorHittable || descendantHittable || ancestorCollection;
}
if (options.compact) {
const hasText = Boolean(node.label && node.label.trim().length > 0);
const hasId = Boolean(node.identifier && node.identifier.trim().length > 0);
return hasText || hasId || Boolean(node.hittable);
return hasMeaningfulText || hasMeaningfulId || Boolean(node.hittable);
}
if (isStructural || isVisual) {
if (node.hittable) return true;
if (hasMeaningfulText) return true;
if (hasMeaningfulId && descendantHittable) return true;
return descendantHittable;
}
return true;
}

function isCollectionContainerType(type: string | null): boolean {
if (!type) return false;
const normalized = normalizeAndroidType(type);
return (
normalized.includes('recyclerview') ||
normalized.includes('listview') ||
normalized.includes('gridview')
);
}

function normalizeAndroidType(type: string | null): string {
if (!type) return '';
return type.toLowerCase();
}

function isStructuralAndroidType(type: string): boolean {
const short = type.split('.').pop() ?? type;
return (
short.includes('layout') ||
short === 'viewgroup' ||
short === 'view'
);
}

function isGenericAndroidId(value: string): boolean {
const trimmed = value.trim();
if (!trimmed) return false;
return /^[\w.]+:id\/[\w.-]+$/i.test(trimmed);
}

function findScopeNode(root: AndroidNode, scope: string): AndroidNode | null {
const query = scope.toLowerCase();
const stack: AndroidNode[] = [...root.children];
Expand Down
100 changes: 74 additions & 26 deletions src/utils/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,10 @@ type SnapshotNode = {

export function formatSnapshotText(
data: Record<string, unknown>,
options: { raw?: boolean } = {},
options: { raw?: boolean; flatten?: boolean } = {},
): string {
const nodes = (data.nodes ?? []) as SnapshotNode[];
const rawNodes = data.nodes;
const nodes = Array.isArray(rawNodes) ? (rawNodes as SnapshotNode[]) : [];
const truncated = Boolean(data.truncated);
const appName = typeof data.appName === 'string' ? data.appName : undefined;
const appBundleId = typeof data.appBundleId === 'string' ? data.appBundleId : undefined;
Expand All @@ -39,13 +40,17 @@ export function formatSnapshotText(
if (appBundleId) meta.push(`App: ${appBundleId}`);
const header = `Snapshot: ${nodes.length} nodes${truncated ? ' (truncated)' : ''}`;
const prefix = meta.length > 0 ? `${meta.join('\n')}\n` : '';
if (!Array.isArray(nodes) || nodes.length === 0) {
if (nodes.length === 0) {
return `${prefix}${header}\n`;
}
if (options.raw) {
const rawLines = nodes.map((node) => JSON.stringify(node));
return `${prefix}${header}\n${rawLines.join('\n')}\n`;
}
if (options.flatten) {
const flatLines = nodes.map((node) => formatSnapshotLine(node, 0, false));
return `${prefix}${header}\n${flatLines.join('\n')}\n`;
}
const hiddenGroupDepths: number[] = [];
const lines: string[] = [];
for (const node of nodes) {
Expand All @@ -62,28 +67,59 @@ export function formatSnapshotText(
const adjustedDepth = isHiddenGroup
? depth
: Math.max(0, depth - hiddenGroupDepths.length);
const indent = ' '.repeat(adjustedDepth);
const ref = node.ref ? `@${node.ref}` : '';
const flags = [
node.enabled === false ? 'disabled' : null,
]
.filter(Boolean)
.join(', ');
const flagText = flags ? ` [${flags}]` : '';
const textPart = label ? ` "${label}"` : '';
if (isHiddenGroup) {
lines.push(`${indent}${ref} [${type}]${flagText}`.trimEnd());
continue;
}
lines.push(`${indent}${ref} [${type}]${textPart}${flagText}`.trimEnd());
lines.push(formatSnapshotLine(node, adjustedDepth, isHiddenGroup));
}
return `${prefix}${header}\n${lines.join('\n')}\n`;
}

function formatSnapshotLine(node: SnapshotNode, depth: number, hiddenGroup: boolean): string {
const type = formatRole(node.type ?? 'Element');
const label = displayLabel(node, type);
const indent = ' '.repeat(depth);
const ref = node.ref ? `@${node.ref}` : '';
const flags = [node.enabled === false ? 'disabled' : null].filter(Boolean).join(', ');
const flagText = flags ? ` [${flags}]` : '';
const textPart = label ? ` "${label}"` : '';
if (hiddenGroup) {
return `${indent}${ref} [${type}]${flagText}`.trimEnd();
}
return `${indent}${ref} [${type}]${textPart}${flagText}`.trimEnd();
}

function displayLabel(node: SnapshotNode, type: string): string {
const label = node.label?.trim();
if (label) return label;
const value = node.value?.trim();
if (value) return value;
const identifier = node.identifier?.trim();
if (!identifier) return '';
if (isGenericResourceId(identifier) && (type === 'group' || type === 'image' || type === 'list' || type === 'collection')) {
return '';
}
return identifier;
}

function isGenericResourceId(value: string): boolean {
return /^[\w.]+:id\/[\w.-]+$/i.test(value);
}

function formatRole(type: string): string {
const raw = type;
let normalized = type.replace(/XCUIElementType/gi, '').toLowerCase();
if (normalized.startsWith("ax")) {
normalized = normalized.replace(/^ax/, "");
const isAndroidClass =
raw.includes('.') &&
(raw.startsWith('android.') || raw.startsWith('androidx.') || raw.startsWith('com.'));
if (normalized.startsWith('ax')) {
normalized = normalized.replace(/^ax/, '');
}
if (normalized.includes('.')) {
normalized = normalized
.replace(/^android\.widget\./, '')
.replace(/^android\.view\./, '')
.replace(/^android\.webkit\./, '')
.replace(/^androidx\./, '')
.replace(/^com\.google\.android\./, '')
.replace(/^com\.android\./, '');
}
switch (normalized) {
case 'application':
Expand All @@ -93,24 +129,40 @@ function formatRole(type: string): string {
case 'tabbar':
return 'tab-bar';
case 'button':
case 'imagebutton':
return 'button';
case 'link':
return 'link';
case 'cell':
return 'cell';
case 'statictext':
case 'checkedtextview':
return 'text';
case 'textfield':
case 'edittext':
return 'text-field';
case 'textview':
return isAndroidClass ? 'text' : 'text-view';
case 'textarea':
return 'text-view';
case 'switch':
return 'switch';
case 'slider':
return 'slider';
case 'image':
case 'imageview':
return 'image';
case 'table':
case 'webview':
return 'webview';
case 'framelayout':
case 'linearlayout':
case 'relativelayout':
case 'constraintlayout':
case 'viewgroup':
case 'view':
return 'group';
case 'listview':
case 'recyclerview':
return 'list';
case 'collectionview':
return 'collection';
Expand All @@ -122,12 +174,6 @@ function formatRole(type: string): string {
return 'group';
case 'window':
return 'window';
case 'statictext':
return 'text';
case 'textfield':
return 'text-field';
case 'textarea':
return 'text-view';
case 'checkbox':
return 'checkbox';
case 'radio':
Expand All @@ -137,6 +183,8 @@ function formatRole(type: string): string {
case 'toolbar':
return 'toolbar';
case 'scrollarea':
case 'scrollview':
case 'nestedscrollview':
return 'scroll-area';
case 'table':
return 'table';
Expand Down
Loading