From 65ff45ec31297f0c20ae1fd077fb359d8d5aac56 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 22:38:47 -0500 Subject: [PATCH 1/2] =?UTF-8?q?test(coverage):=20lane=205=20follow-up=20?= =?UTF-8?q?=E2=80=94=20UI=20tests=20for=20remaining=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds instrumented Compose UI tests for the screens #250/#274 left uncovered, so lane 6's ui-package coverage ratchet (#251, >=95%) can pass: - ColorSwatchRow (compose/format): none entry + swatch rendering, selection callbacks, and selected-state semantics. - LockScreen: locked title/body, optional error text, unlock callback. - AddAnotherAccountScreen: confirmation + both onboarding choices. - SignatureEditScreen: real ViewModel over an in-memory Room-backed SignatureRepository — new-vs-edit title, create/update round-trips. - ReportReviewScreen: real ViewModel over a file-backed ReportStore (submitter stubbed disabled) — disclaimer/fields render, Submit gated on comment length + email validity, discard deletes and leaves. - AppPasswordSetupScreen: real ViewModel over FakeAccountRepository — provider chrome + credential add, and the app-password help link asserted via Espresso-Intents (mirrors AccountPickerScreenTest) so no real browser opens. All 23 tests pass locally on an API 36 emulator. Closes #275 Co-Authored-By: Claude Opus 4.8 --- .../AppPasswordSetupScreenTest.kt | 111 ++++++++++++++ .../ui/compose/format/ColorSwatchRowTest.kt | 90 ++++++++++++ .../org/libremail/ui/lock/LockScreenTest.kt | 69 +++++++++ .../onboarding/AddAnotherAccountScreenTest.kt | 66 +++++++++ .../ui/reporting/ReportReviewScreenTest.kt | 136 ++++++++++++++++++ .../ui/settings/SignatureEditScreenTest.kt | 135 +++++++++++++++++ 6 files changed, 607 insertions(+) create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/compose/format/ColorSwatchRowTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/lock/LockScreenTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/settings/SignatureEditScreenTest.kt diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt new file mode 100644 index 0000000..4118efd --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreenTest.kt @@ -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() + + 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() + } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/format/ColorSwatchRowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/format/ColorSwatchRowTest.kt new file mode 100644 index 0000000..f312926 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/format/ColorSwatchRowTest.kt @@ -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() + + 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() + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/lock/LockScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/lock/LockScreenTest.kt new file mode 100644 index 0000000..1c54506 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/lock/LockScreenTest.kt @@ -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() + + 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 } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenTest.kt new file mode 100644 index 0000000..f15ca22 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenTest.kt @@ -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() + + 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 } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt new file mode 100644 index 0000000..75fd0f4 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/reporting/ReportReviewScreenTest.kt @@ -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() + + 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 { 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 } + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SignatureEditScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SignatureEditScreenTest.kt new file mode 100644 index 0000000..2a8b42e --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SignatureEditScreenTest.kt @@ -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() + + 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", "

hi

") } + 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", "

hi

") } + 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" + } + } +} From e0066004243ff60e164f3fec633364a54ed04417 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 22:54:37 -0500 Subject: [PATCH 2/2] test-infra: reliable local instrumented-test helper (connectedDebugAndroidTest + emulator hygiene) Local Gradle Managed Device tasks (apiXXDebugAndroidTest) fail on this machine: GMD's AVD snapshot step times out under AEHD 2.2 (AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException), though the emulator itself boots fine. CI is unaffected (it uses connectedDebugAndroidTest, not GMD). Add .claude/skills/preflight/local_instrumented.sh, which cold-boots ONE emulator by hand (-no-snapshot, no GMD) and runs :app:connectedDebugAndroidTest filtered to a targeted set of test classes -- the same technique CI and api37_e2e.py already use. The helper is deliberately targeted (the full ~114-test suite tends to wedge mid-run on this box) and enforces emulator hygiene: it force-kills stray qemu/emulator processes before booting, tears the emulator down afterward, and exits non-zero if an orphaned qemu-system-x86_64-headless.exe survives -- accumulated orphans have frozen this machine. Ships with a documented header and a short sibling README. Closes #269 Co-Authored-By: Claude Opus 4.8 --- .../preflight/local_instrumented.README.md | 43 +++ .../skills/preflight/local_instrumented.sh | 245 ++++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 .claude/skills/preflight/local_instrumented.README.md create mode 100755 .claude/skills/preflight/local_instrumented.sh diff --git a/.claude/skills/preflight/local_instrumented.README.md b/.claude/skills/preflight/local_instrumented.README.md new file mode 100644 index 0000000..6128726 --- /dev/null +++ b/.claude/skills/preflight/local_instrumented.README.md @@ -0,0 +1,43 @@ + + +# `local_instrumented.sh` — reliable local instrumented/E2E runs + +A helper for running LibreMail's instrumented / E2E tests **locally** without Gradle +Managed Devices (GMD). Companion to `api37_e2e.py`; born from issue #269. + +## Usage + +```bash +# from the repo root, in Git Bash: +.claude/skills/preflight/local_instrumented.sh org.libremail.ui.compose.ComposeScreenE2ETest +# multiple classes (comma-separated, no spaces): +.claude/skills/preflight/local_instrumented.sh org.libremail.a.FooTest,org.libremail.b.BarTest +``` + +It cold-boots **one** emulator (`-no-snapshot`, no GMD), waits for `sys.boot_completed`, +runs `:app:connectedDebugAndroidTest` filtered to the class(es) you pass, then tears the +emulator down and verifies no orphaned `qemu` process is left behind (exit **3** if one is). + +## Why (short version) + +- **GMD is broken locally on this box.** `apiXXDebugAndroidTest` fails in GMD's snapshot + step — `AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation + timed out` (AEHD 2.2 can't save/load the snapshot). The emulator itself boots fine; only + GMD's snapshot machinery is broken. CI is unaffected (it uses `connectedDebugAndroidTest`, + not GMD). See issue #269. +- **Keep runs targeted.** The full ~114-test suite tends to wedge mid-run on this machine; + small, targeted class sets do not. That's why the script requires an explicit class list — + run only what you changed. The full matrix is CI's job. +- **Emulator hygiene is mandatory.** A hung `adb emu kill` leaves a detached + `qemu-system-x86_64-headless.exe`; accumulated orphans have frozen this machine. The + script force-kills stragglers before booting and after tearing down, and fails loudly if + a zombie survives. + +See the header comment of `local_instrumented.sh` for the full rationale, requirements, and +the `LOCAL_INSTRUMENTED_*` environment overrides (AVD name, JDK home, boot timeout, …). + +## Requirements + +Git Bash; Android SDK `emulator` + `adb` on `PATH`; a JDK **17–21** (AGP 9.2 fails on 25+ — +the script pins `JAVA_HOME` to a known JDK 21, overridable via `LOCAL_INSTRUMENTED_JDK`); and +a free hardware hypervisor (shut down VirtualBox / other VMs first). diff --git a/.claude/skills/preflight/local_instrumented.sh b/.claude/skills/preflight/local_instrumented.sh new file mode 100755 index 0000000..b19ef77 --- /dev/null +++ b/.claude/skills/preflight/local_instrumented.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: GPL-3.0-or-later +# +# local_instrumented.sh — reliable LOCAL instrumented / E2E test runner for LibreMail. +# +# Usage: local_instrumented.sh [,,...] +# Example: +# .claude/skills/preflight/local_instrumented.sh \ +# org.libremail.ui.compose.ComposeScreenE2ETest +# .claude/skills/preflight/local_instrumented.sh \ +# org.libremail.ui.compose.ComposeScreenE2ETest,org.libremail.ui.compose.RecipientChipTest +# +# ============================================================================= +# WHY THIS SCRIPT EXISTS (issue #269) +# ----------------------------------------------------------------------------- +# On this machine (Windows + the AEHD 2.2 hypervisor) the Gradle Managed Device +# (GMD) instrumented tasks — `apiXXDebugAndroidTest` — FAIL during setup. GMD tries +# to save/load an AVD *snapshot* and AEHD 2.2 cannot complete it: +# +# AvdSnapshotHandler$EmulatorSnapshotCannotCreatedException: Snapshot creation timed out +# +# GMD retries the snapshot ~5x, rebooting the AVD each time — that endless reboot is +# the "cycling" that eats hours. The emulator ITSELF is healthy (8 GB RAM, +# `sys.boot_completed=1`, shell-responsive); only GMD's snapshot step is broken. So +# every LOCAL GMD task is affected: coverage lanes 3/5 (#248/#250) and the /preflight +# api35/api36 steps (#266). CI is unaffected — it uses reactivecircus/android-emulator-runner +# + `connectedDebugAndroidTest`, never GMD. +# +# THE RELIABLE LOCAL PATH (this script): +# Cold-boot ONE emulator by hand with `-no-snapshot` (no GMD, no snapshot machinery), +# then run `:app:connectedDebugAndroidTest` — the exact technique CI and +# `api37_e2e.py` already use. We reuse a GMD-provisioned AVD by name so we don't have +# to re-download a system image; GMD re-provisions its own copy on its next run, so +# the `-wipe-data` cold boot here does not disturb it. +# +# ============================================================================= +# KEEP RUNS TARGETED — THE ~114-TEST MID-SUITE WEDGE +# ----------------------------------------------------------------------------- +# Running the WHOLE instrumented suite (~114 tests) via `connectedDebugAndroidTest` +# on this box tends to wedge partway through — the emulator stops making progress +# mid-run. Small, targeted class sets do NOT hit that wedge. That is why this helper +# takes an explicit `[,...]` argument and filters the run +# with `-Pandroid.testInstrumentationRunnerArguments.class=...` instead of running +# everything. Run the class(es) you actually changed; do not use this to run the full +# suite (that is CI's / preflight's job across the API matrix). +# +# ============================================================================= +# FREEZE / HYGIENE RATIONALE — WHY THE ORPHAN-KILL + TEARDOWN VERIFY ARE MANDATORY +# ----------------------------------------------------------------------------- +# A hung `adb emu kill` (or an interrupted run) leaves a detached +# `qemu-system-x86_64-headless.exe` behind. These orphans do not show up in +# `adb devices`, they keep holding the hypervisor + RAM, and accumulated orphans have +# FROZEN this machine outright. So this script: +# * PREAMBLE — force-kills any pre-existing qemu/emulator processes and resets the +# adb server BEFORE booting, so we always start from a clean slate. +# * TEARDOWN — `adb emu kill`, then re-checks `tasklist` for qemu and force-kills any +# survivor. The teardown runs even on Ctrl-C / error (EXIT/INT/TERM trap). +# * VERIFY — if a qemu process is STILL alive after the force-kill, the script exits +# non-zero (code 3) so the leak is never silently ignored. +# Never leave an emulator running after this script; if it exits 3, hunt the zombie +# down by hand (`tasklist | grep -i qemu`; `taskkill //F //IM qemu-system-x86_64-headless.exe`). +# +# ============================================================================= +# REQUIREMENTS +# * Git Bash (this is a bash script; it shells out to Windows `tasklist`/`taskkill`). +# * Android SDK `emulator` + `adb` on PATH (SDK at C:\Android here). +# * A JDK 17–21 for the Gradle daemon — AGP 9.2 fails on JDK 25+. This script pins +# JAVA_HOME to a known JDK 21 (override with LOCAL_INSTRUMENTED_JDK) because the +# ambient JAVA_HOME on this box points at JDK 25. +# * A free hardware hypervisor (VT-x/WHPX/AEHD). Shut down VirtualBox / other VMs first +# or the AVD hangs at 0% CPU and never reaches sys.boot_completed. +# +# Overridable via environment (defaults target THIS machine): +# LOCAL_INSTRUMENTED_AVD AVD name to boot (dev36_google_apis_x86_64_Pixel_2) +# ANDROID_AVD_HOME AVD home dir (C:/Users/jasonross/.android/avd/gradle-managed) +# LOCAL_INSTRUMENTED_JDK JDK 17–21 home (Eclipse Adoptium jdk-21.0.11.10-hotspot) +# LOCAL_INSTRUMENTED_SERIAL adb serial (emulator-5554) +# LOCAL_INSTRUMENTED_BOOT_TIMEOUT boot wait seconds (300) +# ============================================================================= + +set -uo pipefail + +# ---- configuration (env-overridable; defaults are correct for this machine) ----------- +AVD_NAME="${LOCAL_INSTRUMENTED_AVD:-dev36_google_apis_x86_64_Pixel_2}" +AVD_HOME="${ANDROID_AVD_HOME:-C:/Users/jasonross/.android/avd/gradle-managed}" +JDK_HOME="${LOCAL_INSTRUMENTED_JDK:-C:/Program Files/Eclipse Adoptium/jdk-21.0.11.10-hotspot}" +SERIAL="${LOCAL_INSTRUMENTED_SERIAL:-emulator-5554}" +BOOT_TIMEOUT="${LOCAL_INSTRUMENTED_BOOT_TIMEOUT:-300}" +QEMU_IMAGE="qemu-system-x86_64-headless.exe" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../../.." && pwd)" # .claude/skills/preflight -> repo root +GRADLEW="${REPO_ROOT}/gradlew" +EMU_LOG="${TMPDIR:-/tmp}/libremail-local-instrumented-emulator.log" + +EMU_PID="" +TEST_EXIT=1 +ZOMBIE=0 +TEARDOWN_DONE=0 + +log() { printf '\n=== %s ===\n' "$*"; } +warn() { printf 'WARNING: %s\n' "$*" >&2; } +die() { printf 'ERROR: %s\n' "$*" >&2; exit 2; } + +# All emulator/qemu processes Windows currently sees (empty string if none). +list_emu_procs() { tasklist 2>/dev/null | grep -iE 'qemu|emulator' || true; } +list_qemu() { tasklist 2>/dev/null | grep -i 'qemu' || true; } + +# ---- argument parsing ----------------------------------------------------------------- +TEST_CLASSES="${1:-}" +if [[ -z "${TEST_CLASSES}" ]]; then + cat >&2 <<'USAGE' +usage: local_instrumented.sh [,,...] + +Cold-boots ONE emulator (no GMD, no snapshot) and runs :app:connectedDebugAndroidTest +filtered to the given instrumented test class(es). Keep the set small and targeted — +see the header for the ~114-test mid-suite wedge. +USAGE + exit 2 +fi + +# ---- preconditions -------------------------------------------------------------------- +command -v emulator >/dev/null 2>&1 || die "emulator not on PATH (install Android SDK emulator)." +command -v adb >/dev/null 2>&1 || die "adb not on PATH (install Android SDK platform-tools)." +command -v tasklist >/dev/null 2>&1 || die "tasklist not found — this helper targets Windows/Git Bash." +[[ -f "${GRADLEW}" ]] || die "gradlew not found at ${GRADLEW}." +[[ -d "${JDK_HOME}" ]] || die "JDK 17-21 not found at '${JDK_HOME}'. Set LOCAL_INSTRUMENTED_JDK." +[[ -f "${AVD_HOME}/${AVD_NAME}.ini" ]] || \ + die "AVD '${AVD_NAME}' not found under '${AVD_HOME}'. Set LOCAL_INSTRUMENTED_AVD / ANDROID_AVD_HOME. + (GMD AVDs are created by any local apiXXDebugAndroidTest run.)" + +export JAVA_HOME="${JDK_HOME}" +export ANDROID_AVD_HOME="${AVD_HOME}" + +# ---- teardown: always runs (normal exit, error, or Ctrl-C) ---------------------------- +teardown() { + [[ "${TEARDOWN_DONE}" == "1" ]] && return 0 + TEARDOWN_DONE=1 + + log "Teardown: killing emulator and verifying no orphaned qemu remains" + adb -s "${SERIAL}" emu kill >/dev/null 2>&1 || true + sleep 2 + + # Belt-and-suspenders: kill the emulator launcher process we started, if still alive. + if [[ -n "${EMU_PID}" ]] && kill -0 "${EMU_PID}" 2>/dev/null; then + kill "${EMU_PID}" 2>/dev/null || true + sleep 1 + kill -9 "${EMU_PID}" 2>/dev/null || true + fi + + # Verify: any surviving qemu is a machine-freezing zombie — force-kill and re-check. + local remaining + remaining="$(list_qemu)" + if [[ -n "${remaining}" ]]; then + warn "qemu still present after 'adb emu kill'; force-killing:" + printf '%s\n' "${remaining}" >&2 + taskkill //F //IM "${QEMU_IMAGE}" >/dev/null 2>&1 || true + # Sweep any other stray qemu-system image name, too. + taskkill //F //IM "qemu-system-x86_64.exe" >/dev/null 2>&1 || true + sleep 2 + remaining="$(list_qemu)" + if [[ -n "${remaining}" ]]; then + warn "qemu ZOMBIE survived teardown — kill it by hand or the machine may freeze:" + printf '%s\n' "${remaining}" >&2 + ZOMBIE=1 + fi + fi + + adb kill-server >/dev/null 2>&1 || true +} +trap teardown EXIT INT TERM + +# ---- 1. orphan-kill preamble ---------------------------------------------------------- +log "Orphan-kill preamble: ensuring a clean slate before boot" +existing="$(list_emu_procs)" +if [[ -n "${existing}" ]]; then + warn "Pre-existing emulator/qemu processes found — force-killing them first:" + printf '%s\n' "${existing}" >&2 + taskkill //F //IM "${QEMU_IMAGE}" //IM "emulator.exe" >/dev/null 2>&1 || true + sleep 2 +else + echo "No pre-existing qemu/emulator processes." +fi +adb kill-server >/dev/null 2>&1 || true +adb start-server >/dev/null 2>&1 || true + +# ---- 2. cold-boot ONE emulator (no snapshot) ------------------------------------------ +log "Cold-booting @${AVD_NAME} (no GMD, no snapshot); log -> ${EMU_LOG}" +emulator "@${AVD_NAME}" \ + -no-window -no-snapshot -no-boot-anim -no-audio \ + -gpu auto-no-window -cores 8 -wipe-data \ + >"${EMU_LOG}" 2>&1 & +EMU_PID=$! +echo "emulator launcher pid=${EMU_PID}" + +echo "Waiting up to ${BOOT_TIMEOUT}s for sys.boot_completed on ${SERIAL}..." +deadline=$(( $(date +%s) + BOOT_TIMEOUT )) +booted=0 +while (( $(date +%s) < deadline )); do + if ! kill -0 "${EMU_PID}" 2>/dev/null; then + warn "emulator process exited during boot; last log lines:" + tail -n 40 "${EMU_LOG}" >&2 || true + break + fi + state="$(adb -s "${SERIAL}" get-state 2>/dev/null | tr -d '\r')" + if [[ "${state}" == "device" ]]; then + bc="$(adb -s "${SERIAL}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r\n ')" + if [[ "${bc}" == "1" ]]; then booted=1; break; fi + fi + sleep 3 +done + +if [[ "${booted}" != "1" ]]; then + warn "Emulator did not reach sys.boot_completed within ${BOOT_TIMEOUT}s." + tail -n 40 "${EMU_LOG}" >&2 || true + # teardown runs via the EXIT trap; surface a boot failure distinctly. + exit 4 +fi +echo "Emulator booted." + +# Dismiss the keyguard (mirrors CI + api37_e2e.py). Best-effort: a cold -wipe-data boot +# rarely needs it, and the input service can lose a race right after boot. +adb -s "${SERIAL}" shell input keyevent 82 >/dev/null 2>&1 || true + +# ---- 3. run the targeted instrumented tests ------------------------------------------- +log "Running :app:connectedDebugAndroidTest for: ${TEST_CLASSES}" +echo "JAVA_HOME=${JAVA_HOME}" +"${GRADLEW}" :app:connectedDebugAndroidTest \ + "-Pandroid.testInstrumentationRunnerArguments.class=${TEST_CLASSES}" \ + --stacktrace +TEST_EXIT=$? + +# ---- 4. teardown + verify, then exit -------------------------------------------------- +teardown + +if (( ZOMBIE != 0 )); then + warn "Exiting 3: a qemu zombie was left behind (see above) — clean it up before the next run." + exit 3 +fi +if (( TEST_EXIT != 0 )); then + warn "connectedDebugAndroidTest failed (exit ${TEST_EXIT}). Report: app/build/reports/androidTests/connected/" + exit "${TEST_EXIT}" +fi +log "PASS — instrumented tests green for: ${TEST_CLASSES}" +exit 0