From a09cc7df0653274599b7fc5189e4aa07270bf9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 22 Oct 2026 10:12:56 +0200 Subject: [PATCH] test(android): pin that a child vanishing mid-read reads as gone Route AccessibilityNodeReads.child through a generic nullIfGone seam so the JVM suite can reach the catch, and document that a null node is a programmer error the catch would also hide. Co-Authored-By: Claude Sonnet 5.5 --- .../AccessibilityNodeReads.java | 21 ++++++++--- .../AccessibilityReadBatchTest.java | 36 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java index 752e0429cb..6f586de206 100644 --- a/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java +++ b/android/snapshot-helper/src/main/java/com/callstack/agentdevice/snapshothelper/AccessibilityNodeReads.java @@ -30,17 +30,30 @@ static AccessibilityNodeInfo root(AccessibilityWindowInfo window) { return window.getRoot(); } + interface Read { + T read(); + } + /** * The child, or null when it is gone. When the child's window closes during the read, the * framework can throw a NullPointerException from its result integrity check instead of answering * null (seen on API 35 while an alert dialog closed), so that throw means the same as null. + * + *

A null {@code node} is a programmer error, but the same catch hides its NullPointerException + * as a gone child. */ static AccessibilityNodeInfo child(AccessibilityNodeInfo node, int index) { + return nullIfGone( + () -> + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + ? node.getChild(index, PREFETCH_STRATEGY) + : node.getChild(index)); + } + + /** Runs a framework read that answers null for a vanished node and can throw instead. */ + static T nullIfGone(Read read) { try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - return node.getChild(index, PREFETCH_STRATEGY); - } - return node.getChild(index); + return read.read(); } catch (NullPointerException vanished) { return null; } diff --git a/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityReadBatchTest.java b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityReadBatchTest.java index a6e2c7222e..cc2463658a 100644 --- a/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityReadBatchTest.java +++ b/android/snapshot-helper/src/test/java/com/callstack/agentdevice/snapshothelper/AccessibilityReadBatchTest.java @@ -14,6 +14,7 @@ static void run() throws Exception { releasesUnreadResultsAfterFailure(); waitsForReadersBeforeReleasingTheParent(); preservesInterruptionDuringCleanup(); + readsAChildThatVanishedMidReadAsGone(); } private static void queuesConcurrentReadsAndPreservesOrder() throws Exception { @@ -134,6 +135,41 @@ private static void preservesInterruptionDuringCleanup() { } } + private static void readsAChildThatVanishedMidReadAsGone() { + List recycled = new ArrayList<>(); + try (AccessibilityReadBatch batch = + new AccessibilityReadBatch<>( + 0, 3, + index -> + AccessibilityNodeReads.nullIfGone( + () -> { + if (index == 1) { + throw new NullPointerException("window closed"); + } + if (index == 2) { + throw new IllegalStateException("not a vanished child"); + } + return index; + }), + recycled::add)) { + if (batch.get(0) != 0) { + throw new AssertionError("Expected the first child"); + } + if (batch.get(1) != null) { + throw new AssertionError("A child that vanished mid-read must read as gone"); + } + try { + batch.get(2); + throw new AssertionError("Other read failures must propagate"); + } catch (IllegalStateException expected) { + // Only the vanished-window throw means a gone child. + } + } + if (!recycled.equals(Arrays.asList(0))) { + throw new AssertionError("Only the present child must be released: " + recycled); + } + } + private static void await(CountDownLatch latch) { try { if (!latch.await(2, TimeUnit.SECONDS)) {