WIP: merge queue: checking main (d7429df) and [#467 + #469] together #473

Closed
mergify[bot] wants to merge 3 commits from mergify/merge-queue/09fdb9abd8 into main
2 changed files with 88 additions and 65 deletions
@@ -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
}
}
}
@@ -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
}
}
}