diff --git a/src/platforms/android/__tests__/ui-hierarchy.test.ts b/src/platforms/android/__tests__/ui-hierarchy.test.ts index 2dfe4bd85..d14a133e2 100644 --- a/src/platforms/android/__tests__/ui-hierarchy.test.ts +++ b/src/platforms/android/__tests__/ui-hierarchy.test.ts @@ -1,5 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; +import { buildSnapshotState } from '../../../daemon/handlers/snapshot-capture.ts'; +import { isNodeVisibleOnScreen } from '../../../snapshot/mobile-snapshot-semantics.ts'; import { androidUiNodes, parseUiHierarchy } from '../ui-hierarchy.ts'; test('parseUiHierarchy does not truncate when no max node count is requested', () => { @@ -85,6 +87,31 @@ test('parseUiHierarchy keeps visible Android nodes with meaningful test identifi ); }); +test('interactive Android snapshots keep a fixed sibling outside filtered scroll content (#1377)', () => { + const xml = ` + + + + + + + + + + +`; + + const parsed = parseUiHierarchy(xml, 800, { interactiveOnly: true }); + const snapshot = buildSnapshotState( + { nodes: parsed.nodes, backend: 'android' }, + { snapshotInteractiveOnly: true }, + ); + const header = snapshot.nodes.find((node) => node.identifier === 'header-action'); + + assert.equal(header?.parentIndex, undefined); + assert.equal(header && isNodeVisibleOnScreen(header, snapshot.nodes), true); +}); + test('parseUiHierarchy reads Android bounds with negative coordinates', () => { const xml = ''; diff --git a/src/platforms/android/ui-hierarchy.ts b/src/platforms/android/ui-hierarchy.ts index 7d5b448f5..8d1675024 100644 --- a/src/platforms/android/ui-hierarchy.ts +++ b/src/platforms/android/ui-hierarchy.ts @@ -190,7 +190,7 @@ function walkUiHierarchyNode( const systemChrome = ancestorSystemChrome || isAndroidSystemChromeWindowResourceId(node.identifier); const currentIndex = include - ? appendAndroidSnapshotNode(state, node, depth, parentIndex, systemChrome) + ? appendAndroidSnapshotNode(state, node, parentIndex, systemChrome) : parentIndex; const nextAncestorHittable = ancestorHittable || Boolean(node.hittable); const nextAncestorCollection = ancestorCollection || isCollectionContainerType(node.type); @@ -211,11 +211,14 @@ function walkUiHierarchyNode( function appendAndroidSnapshotNode( state: AndroidSnapshotBuildState, node: AndroidNode, - depth: number, parentIndex: number | undefined, systemChrome: boolean, ): number { const currentIndex = state.nodes.length; + // Snapshot filtering removes Compose layout wrappers. Keep depth aligned with + // the retained parent edge, rather than the source tree's depth: otherwise a + // fixed sibling that follows scroll content can be re-parented under the last + // retained row by normalizeSnapshotTree's depth fallback (#1377). state.sourceNodes.push(node); state.nodes.push({ index: currentIndex, @@ -229,7 +232,7 @@ function appendAndroidSnapshotNode( focused: node.focused, visibleToUser: node.visibleToUser, hittable: node.hittable, - depth, + depth: compactedAndroidNodeDepth(state.nodes, parentIndex), parentIndex, ...(node.hiddenContentAbove ? { hiddenContentAbove: true } : {}), ...(node.hiddenContentBelow ? { hiddenContentBelow: true } : {}), @@ -238,6 +241,13 @@ function appendAndroidSnapshotNode( return currentIndex; } +function compactedAndroidNodeDepth( + nodes: AndroidRawSnapshotNode[], + parentIndex: number | undefined, +): number { + return parentIndex === undefined ? 0 : (nodes[parentIndex]?.depth ?? -1) + 1; +} + function hasInteractiveDescendant(state: AndroidSnapshotBuildState, node: AndroidNode): boolean { const cached = state.interactiveDescendantMemo.get(node); if (cached !== undefined) return cached;