diff --git a/src/cli.ts b/src/cli.ts index b24f5dcf4..8d101aa63 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,6 +54,7 @@ export async function runCli(argv: string[]): Promise { process.stdout.write( formatSnapshotText((response.data ?? {}) as Record, { raw: flags.snapshotRaw, + flatten: flags.snapshotInteractiveOnly, }), ); if (logTailStopper) logTailStopper(); diff --git a/src/platforms/android/index.ts b/src/platforms/android/index.ts index c09122cda..505008a10 100644 --- a/src/platforms/android/index.ts +++ b/src/platforms/android/index.ts @@ -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(); + 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; @@ -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; } @@ -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]; diff --git a/src/utils/output.ts b/src/utils/output.ts index 01f8ec00b..df93dd29f 100644 --- a/src/utils/output.ts +++ b/src/utils/output.ts @@ -28,9 +28,10 @@ type SnapshotNode = { export function formatSnapshotText( data: Record, - 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; @@ -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) { @@ -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': @@ -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'; @@ -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': @@ -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';