From 9fce0dd6a30bc5aa50ad42623df61fdb4db2ca68 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 13:07:26 +0000 Subject: [PATCH 1/2] test(e2e): harden syncToggleReceivesRealTap with bounded tap retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tapUntilPrefChanges: wait for the switch to be enabled and bounds-stable before each tap attempt, then poll up to 2s for the preference to flip, and retry the whole sequence up to 3 times before failing. Addresses two no-ANR flake classes observed across API-29 CI runs after the UiAutomator-node fix (#266): 1. The tap lands before SyncSettingsActivity finishes applying async state — the switch is laid out but not yet interactive, so the click is ignored. 2. The preference write completes after the 2s window on a loaded emulator; the next attempt observes the change immediately. The assertion stays strict: a genuinely broken toggle still fails the test after maxAttempts taps. Removes the now-unused awaitSyncEnabled helper. Git-Session-Id: d850 --- .../android/NativeWindowInsetsTest.kt | 60 ++++++++++++++++--- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt index 821c4c2a..c76be4ab 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt @@ -210,12 +210,56 @@ class NativeWindowInsetsTest { assertTrue("$what tap must be injected", device.click(bounds.centerX(), bounds.centerY())) } - private fun awaitSyncEnabled(prefs: AWPreferences, expected: Boolean, message: String) { - val deadline = SystemClock.uptimeMillis() + 5000 - while (SystemClock.uptimeMillis() < deadline && prefs.isSyncEnabled() != expected) { - Thread.sleep(100) + /** + * Tap [viewId] and poll for [prefs.isSyncEnabled()] == [expected], retrying the tap + * when the preference does not flip within [pollMs]. Before each attempt the helper + * waits (bounded) for the view to be enabled and its bounds to stabilise across two + * consecutive samples, so a tap that lands before the Activity finishes settling its + * async state does not silently consume an attempt. + * + * Addresses two no-ANR flake classes observed in CI after the UiAutomator-node fix: + * 1. The tap lands while [viewId] is laid out but not yet interactive — the switch + * ignores the event and the preference never changes. + * 2. The preference write completes after [pollMs] ms on a loaded emulator; a + * subsequent attempt then reads the already-changed value immediately. + * + * The assertion stays strict: a genuinely broken toggle causes the test to fail after + * [maxAttempts] taps rather than returning silently. + */ + private fun tapUntilPrefChanges( + scenario: ActivityScenario<*>, + viewId: Int, + what: String, + prefs: AWPreferences, + expected: Boolean, + maxAttempts: Int = 3, + pollMs: Long = 2000L, + ) { + repeat(maxAttempts) { attempt -> + if (attempt > 0) device.waitForIdle(DRAIN_TIMEOUT_MS) + // Wait until the view is enabled and its bounds are stable across two samples. + val bounds = android.graphics.Rect() + val prevBounds = android.graphics.Rect() + val stableDeadline = SystemClock.uptimeMillis() + 2000L + while (SystemClock.uptimeMillis() < stableDeadline) { + var isEnabled = false + scenario.onActivity { activity -> + val view = activity.findViewById(viewId) + isEnabled = view?.isEnabled == true + view?.getGlobalVisibleRect(bounds) + } + if (isEnabled && bounds == prevBounds && bounds.width() > 0) break + prevBounds.set(bounds) + Thread.sleep(50) + } + tapViewCenter(scenario, viewId, what) + val deadline = SystemClock.uptimeMillis() + pollMs + while (SystemClock.uptimeMillis() < deadline && prefs.isSyncEnabled() != expected) { + Thread.sleep(100) + } + if (prefs.isSyncEnabled() == expected) return } - assertEquals(message, expected, prefs.isSyncEnabled()) + assertEquals("A screen tap must change the persisted setting", expected, prefs.isSyncEnabled()) } @Test fun syncToggleReceivesRealTap() { @@ -225,14 +269,12 @@ class NativeWindowInsetsTest { try { ActivityScenario.launch(SyncSettingsActivity::class.java).use { scenario -> scenario.onActivity { assertSafeContent(it) } - tapViewCenter(scenario, R.id.switch_sync_enabled, "Sync switch") - awaitSyncEnabled(prefs, !original, "A screen tap must change the persisted setting") + tapUntilPrefChanges(scenario, R.id.switch_sync_enabled, "Sync switch", prefs, !original) scenario.recreate() device.waitForIdle() scenario.onActivity { assertSafeContent(it) } assertEquals(!original, prefs.isSyncEnabled()) - tapViewCenter(scenario, R.id.switch_sync_enabled, "Sync switch") - awaitSyncEnabled(prefs, original, "A second tap must restore the persisted setting") + tapUntilPrefChanges(scenario, R.id.switch_sync_enabled, "Sync switch", prefs, original) } } finally { prefs.setSyncEnabled(original) From af7425ad706517e328eb84c46b024d5d71b7a061 Mon Sep 17 00:00:00 2001 From: Bob Date: Thu, 17 Sep 2026 13:25:30 +0000 Subject: [PATCH 2/2] fix(e2e): don't reverse late taps or tap before readiness in sync retry Address two correctness issues Greptile flagged on #287's retry helper: - Re-check prefs.isSyncEnabled() before each retry tap. A tap processed just after its poll window closed flips the preference late; retrying blindly reversed the successful transition and could fail a working toggle intermittently. - Track readiness and skip the tap when the enabled + stable-bounds wait times out. Falling through to tapViewCenter could inject a premature or misplaced tap and consume an attempt. If no attempt ever reached readiness, fail with a message naming that condition rather than the tap assertion. Verified: ./gradlew :mobile:compileStandardDebugAndroidTestKotlin builds. Git-Session-Id: ece32b17-aee2-59d1-b14a-b6186e142390 --- .../android/NativeWindowInsetsTest.kt | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt index c76be4ab..4985c439 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt @@ -217,6 +217,10 @@ class NativeWindowInsetsTest { * consecutive samples, so a tap that lands before the Activity finishes settling its * async state does not silently consume an attempt. * + * A readiness wait that times out injects no tap for that attempt (a not-yet-interactive + * view would only consume the tap), and each retry re-checks the preference first, so a + * tap processed just after its poll window closed is not reversed by a blind second tap. + * * Addresses two no-ANR flake classes observed in CI after the UiAutomator-node fix: * 1. The tap lands while [viewId] is laid out but not yet interactive — the switch * ignores the event and the preference never changes. @@ -235,12 +239,20 @@ class NativeWindowInsetsTest { maxAttempts: Int = 3, pollMs: Long = 2000L, ) { + var tapped = false repeat(maxAttempts) { attempt -> - if (attempt > 0) device.waitForIdle(DRAIN_TIMEOUT_MS) + if (attempt > 0) { + device.waitForIdle(DRAIN_TIMEOUT_MS) + // A previous tap may have been processed just after its poll window closed, + // flipping the preference late. Re-check before tapping again: a blind retry + // would immediately reverse the successful transition. + if (prefs.isSyncEnabled() == expected) return + } // Wait until the view is enabled and its bounds are stable across two samples. val bounds = android.graphics.Rect() val prevBounds = android.graphics.Rect() val stableDeadline = SystemClock.uptimeMillis() + 2000L + var ready = false while (SystemClock.uptimeMillis() < stableDeadline) { var isEnabled = false scenario.onActivity { activity -> @@ -248,17 +260,30 @@ class NativeWindowInsetsTest { isEnabled = view?.isEnabled == true view?.getGlobalVisibleRect(bounds) } - if (isEnabled && bounds == prevBounds && bounds.width() > 0) break + if (isEnabled && bounds == prevBounds && bounds.width() > 0) { + ready = true + break + } prevBounds.set(bounds) Thread.sleep(50) } + if (!ready) { + // The view never reached enabled + stable bounds, so tapping now would be + // premature or misplaced and would consume an attempt without a fair chance + // of landing. Skip the tap and let the next attempt retry the readiness wait. + return@repeat + } tapViewCenter(scenario, viewId, what) + tapped = true val deadline = SystemClock.uptimeMillis() + pollMs while (SystemClock.uptimeMillis() < deadline && prefs.isSyncEnabled() != expected) { Thread.sleep(100) } if (prefs.isSyncEnabled() == expected) return } + if (!tapped) { + fail("$what never became enabled with stable bounds; no tap could be injected") + } assertEquals("A screen tap must change the persisted setting", expected, prefs.isSyncEnabled()) }