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;