Merge pull request #279 from JMR-dev/test-275-lane5-followup-screens

test(coverage): lane 5 follow-up — UI tests for remaining screens
This commit was merged in pull request #279.
This commit is contained in:
Jason Ross
2026-07-03 23:10:03 -05:00
committed by GitHub
6 changed files with 607 additions and 0 deletions
@@ -0,0 +1,111 @@
// 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 androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
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.ext.junit.runners.AndroidJUnit4
import org.hamcrest.CoreMatchers.allOf
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.domain.model.MailProvider
import org.libremail.ui.FakeAccountRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for the guided app-password setup screen (#29). Drives the real
* [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.
*/
@RunWith(AndroidJUnit4::class)
class AppPasswordSetupScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val provider = MailProvider.GMAIL
private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args)
private fun setContent(
repository: FakeAccountRepository = FakeAccountRepository(),
onAccountAdded: (String) -> Unit = {},
) {
val viewModel = AppPasswordViewModel(
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to provider.key)),
repository,
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AppPasswordSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel)
}
}
}
@Test
fun rendersProviderTitle_andCredentialFields() {
setContent()
composeTestRule.onNodeWithText(string(R.string.app_password_title, provider.displayName)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_email)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_field)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().assertIsDisplayed()
}
@Test
fun enteringCredentials_andTappingTestAndAdd_addsAccountAndReportsId() {
val repository = FakeAccountRepository()
var addedId: String? = null
setContent(repository = repository, onAccountAdded = { addedId = it })
composeTestRule.onNodeWithText(string(R.string.app_password_email)).performTextInput("me@gmail.com")
composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pw-1234")
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().performClick()
// A successful add persists via the repository and hands the new account id to the caller.
composeTestRule.waitUntil(5_000) {
repository.addedAccount?.email == "me@gmail.com" && addedId == "imap:me@gmail.com"
}
}
/**
* 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.
*/
@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()
Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(provider.appPasswordHelpUrl)))
} finally {
Intents.release()
}
}
}
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose.format
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the shared font-color / highlight swatch row (#78). [ColorSwatchRow] is
* presentational, so it is driven in isolation - independent of the compose formatting toolbar that
* hosts it - mirroring how `ParagraphAlignmentControlTest` exercises its control. Every swatch is a
* TalkBack-labeled, selectable button, so nodes are addressed by their content description.
*/
@RunWith(AndroidJUnit4::class)
class ColorSwatchRowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val red = ColorSwatch(argb = 0xFFD32F2F.toInt(), label = "Red")
private val blue = ColorSwatch(argb = 0xFF1976D2.toInt(), label = "Blue")
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(selectedArgb: Int?, onSelect: (Int?) -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ColorSwatchRow(swatches = listOf(red, blue), selectedArgb = selectedArgb, onSelect = onSelect)
}
}
}
@Test
fun showsNoneEntry_andEverySwatch() {
setContent(selectedArgb = null)
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(red.label).assertIsDisplayed()
composeTestRule.onNodeWithContentDescription(blue.label).assertIsDisplayed()
}
@Test
fun tappingASwatch_reportsItsArgb() {
var picked: Int? = -1
setContent(selectedArgb = null) { picked = it }
composeTestRule.onNodeWithContentDescription(blue.label).performClick()
assertEquals(blue.argb, picked)
}
@Test
fun tappingNoneEntry_reportsNull() {
var picked: Int? = red.argb
setContent(selectedArgb = red.argb) { picked = it }
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).performClick()
assertEquals(null, picked)
}
@Test
fun selectedSwatch_isMarkedSelected_andOthersAreNot() {
setContent(selectedArgb = red.argb)
composeTestRule.onNodeWithContentDescription(red.label).assertIsSelected()
composeTestRule.onNodeWithContentDescription(blue.label).assertIsNotSelected()
// With a color selected, the leading "no color" entry is not the selected one.
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsNotSelected()
}
@Test
fun noSelection_marksTheNoneEntrySelected() {
setContent(selectedArgb = null)
composeTestRule.onNodeWithContentDescription(string(R.string.format_color_none)).assertIsSelected()
composeTestRule.onNodeWithContentDescription(red.label).assertIsNotSelected()
}
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.lock
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the app-lock gate. [LockScreen] is presentational (its biometric prompt is driven by
* the caller via [onUnlock]), so it is exercised in isolation: the locked title/body always show, an
* optional error string appears only when non-null, and the unlock button reports back.
*/
@RunWith(AndroidJUnit4::class)
class LockScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(error: String? = null, onUnlock: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
LockScreen(error = error, onUnlock = onUnlock)
}
}
}
@Test
fun showsLockedTitleBody_andUnlockButton() {
setContent()
composeTestRule.onNodeWithText(string(R.string.app_lock_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_lock_locked_body)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).assertIsDisplayed()
}
@Test
fun noError_hidesErrorText() {
setContent(error = null)
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertDoesNotExist()
}
@Test
fun error_isDisplayed() {
setContent(error = string(R.string.app_lock_unlock_failed))
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock_failed)).assertIsDisplayed()
}
@Test
fun tappingUnlock_invokesOnUnlock() {
var unlocked = false
setContent(onUnlock = { unlocked = true })
composeTestRule.onNodeWithText(string(R.string.app_lock_unlock)).performClick()
composeTestRule.waitUntil(5_000) { unlocked }
}
}
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI tests for the post-add onboarding prompt. [AddAnotherAccountScreen] is presentational — it just
* confirms the add and routes the two choices back to the caller — so it is driven in isolation:
* "Add another" returns to the vendor picker and "No" finishes onboarding.
*/
@RunWith(AndroidJUnit4::class)
class AddAnotherAccountScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(onAddAnother: () -> Unit = {}, onFinish: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
AddAnotherAccountScreen(onAddAnother = onAddAnother, onFinish = onFinish)
}
}
}
@Test
fun showsConfirmation_andBothChoices() {
setContent()
composeTestRule.onNodeWithText(string(R.string.onboarding_account_added_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_prompt)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).assertIsDisplayed()
}
@Test
fun tappingAddAnother_invokesOnAddAnother() {
var addAnother = false
setContent(onAddAnother = { addAnother = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).performClick()
composeTestRule.waitUntil(5_000) { addAnother }
}
@Test
fun tappingNo_invokesOnFinish() {
var finished = false
setContent(onFinish = { finished = true })
composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick()
composeTestRule.waitUntil(5_000) { finished }
}
}
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.reporting
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
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.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.mockk.every
import io.mockk.mockk
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.reporting.DebugReport
import org.libremail.reporting.ReportKind
import org.libremail.reporting.ReportStore
import org.libremail.reporting.ReportSubmitter
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
import java.io.File
/**
* End-to-end UI test for [ReportReviewScreen] + [ReportReviewViewModel] over a real file-backed
* [ReportStore] (temp dir) and a [ReportSubmitter] stubbed as disabled (no ingest endpoint, this
* repo's default): the disclaimer + comment/email fields render, Submit stays disabled until the
* comment reaches the minimum length and the email is valid, and Discard deletes the report and
* leaves the screen. The submit-enqueue/upload path (WorkManager) is deliberately out of scope.
*/
@RunWith(AndroidJUnit4::class)
class ReportReviewScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val context: Context =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
private val storeDir = File(context.cacheDir, "report-review-test")
private val reportId = "r-1"
@Before
fun setUp() {
storeDir.deleteRecursively()
}
@After
fun tearDown() {
storeDir.deleteRecursively()
}
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun report(id: String) = DebugReport(
id = id,
createdAtMillis = 1_000L,
kind = ReportKind.MANUAL,
appVersionName = "1.0",
appVersionCode = 1L,
androidRelease = "14",
androidSdkInt = 34,
deviceManufacturer = "Test",
deviceModel = "Model",
stackTrace = null,
settings = emptyMap(),
logs = emptyList(),
)
private fun setContent(onDone: () -> Unit = {}): ReportStore {
val store = ReportStore(storeDir)
store.save(report(reportId))
val submitter = mockk<ReportSubmitter> { every { isEnabled } returns false }
val viewModel = ReportReviewViewModel(
SavedStateHandle(mapOf(Routes.REPORT_REVIEW_ARG_ID to reportId)),
store,
submitter,
)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
ReportReviewScreen(onDone = onDone, viewModel = viewModel)
}
}
return store
}
@Test
fun rendersDisclaimer_fieldsAndSubmit() {
setContent()
composeTestRule.onNodeWithText(string(R.string.report_pii_disclaimer_title)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.report_comment_label)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.report_email_label)).performScrollTo().assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsDisplayed()
}
@Test
fun submit_isDisabled_whenCommentTooShort_orEmailInvalid() {
setContent()
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsNotEnabled()
}
@Test
fun submit_isEnabled_afterValidCommentAndEmail() {
setContent()
composeTestRule.onNodeWithText(string(R.string.report_comment_label))
.performScrollTo()
.performTextInput("x".repeat(ReportSubmissionRules.MIN_COMMENT_LENGTH))
composeTestRule.onNodeWithText(string(R.string.report_email_label))
.performScrollTo()
.performTextInput("me@example.com")
composeTestRule.onNodeWithText(string(R.string.report_submit)).performScrollTo().assertIsEnabled()
}
@Test
fun tappingDiscard_deletesReport_andLeavesTheScreen() {
var done = false
val store = setContent(onDone = { done = true })
composeTestRule.onNodeWithText(string(R.string.report_discard)).performScrollTo().performClick()
// Discard removes the row; the screen then auto-navigates away once it observes it is gone.
composeTestRule.waitUntil(5_000) { store.find(reportId) == null && done }
}
}
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.compose.ui.test.performTextReplacement
import androidx.lifecycle.SavedStateHandle
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.data.local.AccountDatabase
import org.libremail.data.local.entity.AccountEntity
import org.libremail.data.local.entity.ServerConfigEmbedded
import org.libremail.data.settings.SignatureRepository
import org.libremail.ui.navigation.Routes
import org.libremail.ui.theme.LibreMailTheme
/**
* End-to-end UI test for [SignatureEditScreen] + [SignatureEditViewModel] over a real in-memory Room
* DB and [SignatureRepository]: the new-vs-edit title, saving a freshly entered name (round-tripping
* through the repository, which makes the account's first signature its default), loading an existing
* signature's name into the field, and saving an edit back to the same row.
*/
@RunWith(AndroidJUnit4::class)
class SignatureEditScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private val context: Context =
InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
private val accountId = "imap:a"
private lateinit var db: AccountDatabase
private lateinit var repository: SignatureRepository
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(context, AccountDatabase::class.java).build()
repository = SignatureRepository(db.signatureDao())
// Signatures foreign-key to an account row, so the parent account must exist first.
runBlocking {
db.accountDao().upsert(
AccountEntity(
id = accountId,
email = "a@example.org",
displayName = "A",
authType = "PASSWORD_IMAP",
imap = ServerConfigEmbedded("imap.example.org", 993, "SSL_TLS"),
smtp = ServerConfigEmbedded("smtp.example.org", 465, "SSL_TLS"),
),
)
}
}
@After
fun tearDown() = db.close()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(signatureId: String? = null, onBack: () -> Unit = {}) {
val args = buildMap {
put(Routes.SIGNATURE_EDIT_ARG_ACCOUNT, accountId)
if (signatureId != null) put(Routes.SIGNATURE_EDIT_ARG_ID, signatureId)
}
val viewModel = SignatureEditViewModel(SavedStateHandle(args), repository)
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
SignatureEditScreen(onBack = onBack, viewModel = viewModel)
}
}
}
private fun waitForText(text: String) = composeTestRule.waitUntil(5_000) {
composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty()
}
@Test
fun newSignature_showsNewTitle() {
setContent(signatureId = null)
composeTestRule.onNodeWithText(string(R.string.signature_new_title)).assertIsDisplayed()
}
@Test
fun newSignature_savingEnteredName_persistsViaRepository() {
var backInvoked = false
setContent(signatureId = null, onBack = { backInvoked = true })
composeTestRule.onNodeWithText(string(R.string.signature_name)).performTextInput("Work")
composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick()
// The save round-trips to the DB: the first signature created becomes the account's default.
composeTestRule.waitUntil(5_000) {
runBlocking { repository.getDefault(accountId)?.name } == "Work" && backInvoked
}
}
@Test
fun existingSignature_showsEditTitle_andPrefillsName() {
val id = runBlocking { repository.create(accountId, "Personal", "<p>hi</p>") }
setContent(signatureId = id)
waitForText("Personal")
composeTestRule.onNodeWithText(string(R.string.signature_edit_title)).assertIsDisplayed()
composeTestRule.onNodeWithText("Personal").assertIsDisplayed()
}
@Test
fun existingSignature_savingRenamedName_updatesTheSameRow() {
val id = runBlocking { repository.create(accountId, "Personal", "<p>hi</p>") }
setContent(signatureId = id)
waitForText("Personal")
composeTestRule.onNodeWithText("Personal").performTextReplacement("Renamed")
composeTestRule.onNodeWithText(string(R.string.signature_save)).performClick()
composeTestRule.waitUntil(5_000) {
runBlocking { repository.get(id)?.name } == "Renamed"
}
}
}