From 65eae0f6c76e5e56ff33f6e3c6b32aa60a3f6810 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 8 Jul 2026 19:04:13 -0500 Subject: [PATCH] test(accountsetup): de-flake URL-open link E2E tests via fake LocalUriHandler The outbound-link E2E tests verified the opened page with Espresso-Intents (intending(ACTION_VIEW).respondWith(...) + intended(...)). intended() runs an onView(isRoot()).check(...) whose RootViewPicker waits up to 10s for a window-focused root. On the CI matrix emulator the activity window intermittently reports has-window-focus=false, so the assertion flakes with RootViewPicker$RootViewWithoutFocusException, failing the whole E2E leg and forcing a 9-min retry. The intent stubs were already present and do NOT fix this: no external activity launches, the focus loss is environmental (the same run failed 8 unrelated RootViewPicker-based tests at once). Verify these ACTION_VIEW/browser-open link taps by injecting a recording LocalUriHandler and asserting the exact URL the screen opens. That keeps the tests entirely on Compose interactions, which do not depend on window focus (280+ Compose-only tests passed in the same failing run), so they are deterministic without weakening the assertion (still asserts the provider page / host). Converted (ACTION_VIEW / UriHandler "browser-open" shape): - AppPasswordSetupScreenTest.tappingCreateAppPasswordPage_launchesBrowserIntentToHelpUrl - AppPasswordSetupScreenTest.imapDisabledFailure_showsThePrompt_andHelpLinkOpensTheProviderPage - OutlookImapNoticeScreenTest.tappingImapHelpLink_opensTheMicrosoftArticle Real-intent tests (hasComponent/Settings action, no UriHandler seam) keep Espresso-Intents and are out of scope here. --- .../AppPasswordSetupScreenTest.kt | 83 ++++++++++--------- .../onboarding/OutlookImapNoticeScreenTest.kt | 70 +++++++++------- 2 files changed, 88 insertions(+), 65 deletions(-) diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt index 4a09675..9afcdbe 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt @@ -1,10 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.accountsetup -import android.app.Activity -import android.app.Instrumentation -import android.content.Intent +import android.net.Uri import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText @@ -13,14 +14,9 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle -import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction -import androidx.test.espresso.intent.matcher.IntentMatchers.hasData -import androidx.test.espresso.intent.matcher.UriMatchers.hasHost import androidx.test.ext.junit.runners.AndroidJUnit4 import jakarta.mail.AuthenticationFailedException -import org.hamcrest.CoreMatchers.allOf -import org.hamcrest.CoreMatchers.equalTo +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -35,8 +31,17 @@ import org.libremail.ui.theme.LibreMailTheme * [AppPasswordSetupScreen] + [AppPasswordViewModel] over a [FakeAccountRepository] for the preset * Gmail vendor: the provider-specific chrome renders, entering an email + app password and tapping * "Test & add" persists through the repository and reports the new account id, and tapping the - * "create an app password" help link fires the browser intent. That launch is asserted with - * Espresso-Intents (mirroring `AccountPickerScreenTest`'s Outlook test), so no real browser opens. + * "create an app password" help link opens the provider's help page. + * + * The outbound help links are verified by injecting a recording [UriHandler] for [LocalUriHandler] + * and asserting the URL the screen asked to open — deliberately NOT via Espresso-Intents. The two + * approaches verify the same behaviour, but `Intents.intended(...)` runs an `onView(isRoot())` view + * assertion whose `RootViewPicker` waits up to 10s for a window-focused root; on the CI emulator the + * activity window intermittently reports `has-window-focus=false`, so that assertion flakes with + * `RootViewWithoutFocusException` (an infra flake that fails every `intended()`-based E2E test on the + * affected leg and forces a costly 9-min retry). Driving the link through a fake [UriHandler] keeps + * the whole test on Compose interactions, which do not depend on window focus, so it is deterministic + * — while still asserting the exact provider page the tap opens. */ @RunWith(AndroidJUnit4::class) class AppPasswordSetupScreenTest { @@ -46,6 +51,9 @@ class AppPasswordSetupScreenTest { private val provider = MailProvider.GMAIL + // Captures the URL the screen hands to LocalUriHandler instead of launching a real browser. + private val uriHandler = RecordingUriHandler() + private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args) private fun setContent( @@ -57,8 +65,10 @@ class AppPasswordSetupScreenTest { repository, ) composeTestRule.setContent { - LibreMailTheme(darkTheme = false, dynamicColor = false) { - AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel) + CompositionLocalProvider(LocalUriHandler provides uriHandler) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel) + } } } } @@ -90,34 +100,29 @@ class AppPasswordSetupScreenTest { } /** - * Tapping the "create an app password" link opens the provider's help page via - * [androidx.compose.ui.platform.UriHandler], which starts an `ACTION_VIEW` intent. Stubbing that - * intent both proves the tap launched it and stops a real browser from opening on the device. + * Tapping the "create an app password" link opens the provider's help page via [LocalUriHandler]. + * Asserting the URL captured by [RecordingUriHandler] proves the tap requested the right page + * without launching a real browser (and without the window-focus-dependent Espresso-Intents + * assertion that flakes on CI — see the class comment). */ @Test fun tappingCreateAppPasswordPage_launchesBrowserIntentToHelpUrl() { setContent() - Intents.init() - try { - Intents.intending(hasAction(Intent.ACTION_VIEW)) - .respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)) + composeTestRule.onNodeWithText(string(R.string.app_password_open_page, provider.displayName)) + .performScrollTo() + .performClick() - composeTestRule.onNodeWithText(string(R.string.app_password_open_page, provider.displayName)) - .performScrollTo() - .performClick() - - Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(provider.appPasswordHelpUrl))) - } finally { - Intents.release() - } + assertEquals(provider.appPasswordHelpUrl, uriHandler.lastUri) } /** * When the connection test fails specifically because IMAP is disabled (Gmail's "not enabled for * IMAP use"), the screen surfaces the actionable "turn on IMAP" dialog instead of a generic error, * and its help link opens the provider's enable-IMAP page (#390). Driving the failure through a - * [FakeAccountRepository] exercises the real classification + dialog wiring end to end on device. + * [FakeAccountRepository] exercises the real classification + dialog wiring end to end on device; + * the help link's target is verified through the injected [RecordingUriHandler] (see the class + * comment for why not Espresso-Intents). */ @Test fun imapDisabledFailure_showsThePrompt_andHelpLinkOpensTheProviderPage() { @@ -138,16 +143,20 @@ class AppPasswordSetupScreenTest { } composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, provider.displayName)).assertIsDisplayed() - Intents.init() - try { - Intents.intending(hasAction(Intent.ACTION_VIEW)) - .respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)) + composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).performClick() - composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).performClick() + // Mirrors the previous Espresso hasHost(...) check: the Gmail enable-IMAP page is on Google's + // support host. Verifying the exact host keeps the assertion strength without any focus wait. + assertEquals("support.google.com", Uri.parse(uriHandler.lastUri).host) + } - Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(hasHost(equalTo("support.google.com"))))) - } finally { - Intents.release() + /** A [UriHandler] that records the last opened URL instead of starting a real `ACTION_VIEW` intent. */ + private class RecordingUriHandler : UriHandler { + var lastUri: String? = null + private set + + override fun openUri(uri: String) { + lastUri = uri } } } diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenTest.kt index 5670175..34d2dbe 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenTest.kt @@ -4,23 +4,22 @@ package org.libremail.ui.onboarding import android.app.Activity import android.app.Instrumentation import android.content.Context -import android.content.Intent +import android.net.Uri import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.test.espresso.intent.Intents -import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent -import androidx.test.espresso.intent.matcher.IntentMatchers.hasData -import androidx.test.espresso.intent.matcher.UriMatchers.hasHost import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import net.openid.appauth.AuthorizationManagementActivity -import org.hamcrest.CoreMatchers.allOf -import org.hamcrest.CoreMatchers.equalTo +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -33,11 +32,18 @@ import org.libremail.ui.theme.LibreMailTheme /** * End-to-end UI test for the pre-auth Outlook IMAP-enablement notice (#411). Drives the real * [OutlookImapNoticeScreen] + [AccountSetupViewModel] over a [FakeAccountRepository]: the IMAP - * question and both outbound links render, tapping a help link fires the browser `ACTION_VIEW` - * intent, and tapping the bottom "Sign in" button starts the existing Microsoft OAuth (AppAuth) - * flow. Both launches are asserted with Espresso-Intents (mirroring `AccountPickerScreenTest`), so no - * real browser ever opens; the interstitial → OAuth navigation in the full onboarding graph is - * covered by `OnboardingFlowTest`. + * question and both outbound links render, tapping the "How to enable IMAP" help link opens + * Microsoft's help article, and tapping the bottom "Sign in" button starts the existing Microsoft + * OAuth (AppAuth) flow. + * + * The help link is verified by injecting a recording [UriHandler] for [LocalUriHandler] and asserting + * the opened URL — not via Espresso-Intents, whose `intended(...)` runs an `onView(isRoot())` + * assertion that waits for a window-focused root and flakes with `RootViewWithoutFocusException` on + * the CI emulator (see `AppPasswordSetupScreenTest` for the full write-up). The "Sign in" launch has + * no [UriHandler] seam — AppAuth calls `startActivity` directly — so it stays on Espresso-Intents, + * matched by AppAuth's [AuthorizationManagementActivity] component and stubbed so no real browser + * opens; the interstitial → OAuth navigation in the full onboarding graph is covered by + * `OnboardingFlowTest`. */ @RunWith(AndroidJUnit4::class) class OutlookImapNoticeScreenTest { @@ -48,13 +54,18 @@ class OutlookImapNoticeScreenTest { private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + // Captures the URL the screen hands to LocalUriHandler instead of launching a real browser. + private val uriHandler = RecordingUriHandler() + private fun string(resId: Int) = composeTestRule.activity.getString(resId) private fun setContent(onAccountAdded: (String) -> Unit = {}) { val viewModel = AccountSetupViewModel(OutlookAuthManager(context), FakeAccountRepository()) composeTestRule.setContent { - LibreMailTheme(darkTheme = false, dynamicColor = false) { - OutlookImapNoticeScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel) + CompositionLocalProvider(LocalUriHandler provides uriHandler) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + OutlookImapNoticeScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel) + } } } } @@ -70,27 +81,20 @@ class OutlookImapNoticeScreenTest { } /** - * Tapping the "How to enable IMAP" link opens Microsoft's help article via - * [androidx.compose.ui.platform.UriHandler], which starts an `ACTION_VIEW` intent. Stubbing that - * intent both proves the tap launched it and stops a real browser from opening on the device. + * Tapping the "How to enable IMAP" link opens Microsoft's help article via [LocalUriHandler]. + * Asserting the URL captured by [RecordingUriHandler] proves the tap requested the right page + * without launching a real browser (and without the window-focus-dependent Espresso-Intents + * assertion that flakes on CI — see the class comment). */ @Test fun tappingImapHelpLink_opensTheMicrosoftArticle() { setContent() - Intents.init() - try { - Intents.intending(hasAction(Intent.ACTION_VIEW)) - .respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)) + composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)) + .performScrollTo() + .performClick() - composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)) - .performScrollTo() - .performClick() - - Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(hasHost(equalTo("support.microsoft.com"))))) - } finally { - Intents.release() - } + assertEquals("support.microsoft.com", Uri.parse(uriHandler.lastUri).host) } /** @@ -118,4 +122,14 @@ class OutlookImapNoticeScreenTest { Intents.release() } } + + /** A [UriHandler] that records the last opened URL instead of starting a real `ACTION_VIEW` intent. */ + private class RecordingUriHandler : UriHandler { + var lastUri: String? = null + private set + + override fun openUri(uri: String) { + lastUri = uri + } + } }