@@ -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 . ] + : i d \/ [ \w . - ] + $ / i. test ( trimmed ) ;
726+ }
727+
645728function findScopeNode ( root : AndroidNode , scope : string ) : AndroidNode | null {
646729 const query = scope . toLowerCase ( ) ;
647730 const stack : AndroidNode [ ] = [ ...root . children ] ;
0 commit comments