From 669946ea34053644ca0103949f8b60edc9a3c22e Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Mon, 29 Jun 2026 21:36:41 -0500 Subject: [PATCH] Add CI/release workflows and first Compose UI tests GitHub Actions (every action pinned to its commit SHA, version in a comment): - ci.yml, on pull_request to main, runs three jobs: a debug build, the unit tests, and the instrumented suite on a headless emulator. - release.yml, on workflow_dispatch, builds the release APK, archives the source as zip + tar.gz, and publishes a GitHub release. Compose UI tests (app/src/androidTest), driving the real screens with fake-backed view models so they need no network, database, or Hilt graph: - ManualSetupScreen: submit-button validation, advanced-options toggle, and add-account success/failure. - ComposeScreen: send-button enablement and the send -> close flow. - LibreMailBottomBar: tab rendering and selection callback. Mark gradlew executable (100755) so it runs on the Linux CI runners. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 164 ++++++++++++++++++ .github/workflows/release.yml | 77 ++++++++ .../kotlin/org/libremail/ui/Fakes.kt | 109 ++++++++++++ .../libremail/ui/LibreMailBottomBarTest.kt | 41 +++++ .../ui/accountsetup/ManualSetupScreenTest.kt | 104 +++++++++++ .../libremail/ui/compose/ComposeScreenTest.kt | 109 ++++++++++++ gradlew | 0 7 files changed, 604 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/LibreMailBottomBarTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt mode change 100644 => 100755 gradlew diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c8877f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +name: CI + +on: + pull_request: + branches: [main] + +# A new push to a PR cancels any in-flight run for that PR. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + # SDK packages this project builds against (compileSdk 37 / build-tools 37.0.0). + # Quote the package ids when passed to sdkmanager — the ';' is a shell separator. + ANDROID_PLATFORM: "platforms;android-37.0" + ANDROID_BUILD_TOOLS: "build-tools;37.0.0" + +jobs: + debug-build: + name: Debug build + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up JDK 21 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Assemble debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: debug-apk + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + + unit-tests: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up JDK 21 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + - name: Run unit tests + run: ./gradlew testDebugUnitTest --stacktrace + + - name: Upload unit test report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: unit-test-report + path: app/build/reports/tests/testDebugUnitTest/ + if-no-files-found: warn + + instrumented-tests: + name: Instrumented / UI tests (emulator) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # minSdk is 33; the instrumented suite (SQLCipher, Keystore, Room, Compose UI, + # mail providers) needs no API-37-specific behavior, so it runs on a stable, + # widely-available emulator image rather than the bleeding-edge android-37.0. + api-level: [35] + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up JDK 21 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + # The hardware-accelerated emulator needs KVM, which is gated behind a udev rule. + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Cache AVD snapshot + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }}-google_apis-x86_64 + + # On a cache miss, cold-boot the emulator once so its snapshot can be cached, + # making subsequent runs start from a warm snapshot. + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + with: + api-level: ${{ matrix.api-level }} + target: google_apis + arch: x86_64 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Run instrumented tests + uses: reactivecircus/android-emulator-runner@e89f39f1abbbd05b1113a29cf4db69e7540cae5a # v2.37.0 + with: + api-level: ${{ matrix.api-level }} + target: google_apis + arch: x86_64 + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: ./gradlew connectedDebugAndroidTest --stacktrace + + - name: Upload instrumented test report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: instrumented-test-report-api${{ matrix.api-level }} + path: app/build/reports/androidTests/connected/ + if-no-files-found: warn diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4248315 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +name: Release + +on: + workflow_dispatch: + inputs: + tag: + description: "Release tag to create (e.g. v0.1.0)" + required: true + type: string + prerelease: + description: "Mark this GitHub release as a pre-release" + required: false + type: boolean + default: true + +# Needed to create the tag, the GitHub release, and upload its assets. +permissions: + contents: write + +env: + ANDROID_PLATFORM: "platforms;android-37.0" + ANDROID_BUILD_TOOLS: "build-tools;37.0.0" + +jobs: + release: + name: Build APK and publish release + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up JDK 21 + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: temurin + java-version: "21" + + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install SDK platform and build-tools + run: sdkmanager "$ANDROID_PLATFORM" "$ANDROID_BUILD_TOOLS" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + + # No release keystore is configured in CI, so the build falls back to the debug + # signing key (installable for testing, not for store publication). + - name: Assemble release APK + run: ./gradlew assembleRelease --stacktrace + + - name: Stage release artifacts (APK + source archives) + run: | + set -euo pipefail + tag='${{ inputs.tag }}' + mkdir -p dist + apk="$(find app/build/outputs/apk/release -name '*.apk' -print -quit)" + cp "$apk" "dist/LibreMail-${tag}.apk" + # Source archives contain only git-tracked files at the released commit. + git archive --format=zip --prefix="LibreMail-${tag}/" -o "dist/LibreMail-${tag}-src.zip" HEAD + git archive --format=tar --prefix="LibreMail-${tag}/" HEAD | gzip > "dist/LibreMail-${tag}-src.tar.gz" + ls -l dist + + - name: Create GitHub release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + tag_name: ${{ inputs.tag }} + name: ${{ inputs.tag }} + target_commitish: ${{ github.sha }} + prerelease: ${{ inputs.prerelease }} + generate_release_notes: true + fail_on_unmatched_files: true + files: | + dist/LibreMail-${{ inputs.tag }}.apk + dist/LibreMail-${{ inputs.tag }}-src.zip + dist/LibreMail-${{ inputs.tag }}-src.tar.gz diff --git a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt new file mode 100644 index 0000000..98ff1f0 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui + +import java.io.File +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.libremail.domain.model.Account +import org.libremail.domain.model.Attachment +import org.libremail.domain.model.Draft +import org.libremail.domain.model.ImapConnectionParams +import org.libremail.domain.model.Message +import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.model.OutgoingMessage +import org.libremail.domain.repository.AccountRepository +import org.libremail.domain.repository.MailRepository + +/** + * In-memory [AccountRepository] for Compose UI tests: serves a fixed account list, records the + * account/password handed to [addImapAccount], and returns a configurable connection-test result. + */ +class FakeAccountRepository( + accounts: List = emptyList(), + private var result: Result> = Result.success(listOf("INBOX")), +) : AccountRepository { + + private val accountsFlow = MutableStateFlow(accounts) + + var addedAccount: Account? = null + private set + var addedPassword: String? = null + private set + + override fun observeAccounts(): Flow> = accountsFlow + + override suspend fun testConnection(params: ImapConnectionParams): Result> = result + + override suspend fun addImapAccount(account: Account, password: String): Result> { + addedAccount = account + addedPassword = password + return result + } + + override suspend fun addOutlookAccount( + email: String, + accessToken: String, + authStateJson: String, + ): Result> = result + + override suspend fun deleteAccount(id: String) { + accountsFlow.value = accountsFlow.value.filterNot { it.id == id } + } +} + +/** + * In-memory [MailRepository] for Compose UI tests: records sent messages / saved drafts and returns + * a configurable [sendResult]. Read paths emit empty so screens render their empty states. + */ +class FakeMailRepository( + var sendResult: Result = Result.success(Unit), +) : MailRepository { + + val sentMessages = mutableListOf() + val savedDrafts = mutableListOf() + val deletedDraftIds = mutableListOf() + + override fun observeMessages(): Flow> = flowOf(emptyList()) + + override suspend fun getMessage(id: String): Message? = null + + override suspend fun openMessage(id: String): Result = + Result.failure(UnsupportedOperationException("not used in UI tests")) + + override fun observeAttachments(messageId: String): Flow> = flowOf(emptyList()) + + override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result = + Result.failure(UnsupportedOperationException("not used in UI tests")) + + override suspend fun setStarred(id: String, starred: Boolean): Result = Result.success(Unit) + + override suspend fun deleteMessage(id: String): Result = Result.success(Unit) + + override suspend fun sendMessage(outgoing: OutgoingMessage): Result { + sentMessages += outgoing + return sendResult + } + + override fun observeDrafts(): Flow> = flowOf(emptyList()) + + override suspend fun getDraft(id: String): Draft? = null + + override suspend fun saveDraft(draft: Draft) { + savedDrafts += draft + } + + override suspend fun deleteDraft(id: String) { + deletedDraftIds += id + } + + override fun observeOutbox(): Flow> = flowOf(emptyList()) + + override suspend fun cancelOutboxMessage(id: String) {} + + override suspend fun retryOutbox() {} + + override suspend fun searchServer(query: String) {} + + override suspend fun clearSearchResults() {} +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/LibreMailBottomBarTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/LibreMailBottomBarTest.kt new file mode 100644 index 0000000..7144ed9 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/LibreMailBottomBarTest.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui + +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.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 test for the shared bottom navigation bar: it renders both tabs and reports the tapped tab. */ +@RunWith(AndroidJUnit4::class) +class LibreMailBottomBarTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + @Test + fun rendersBothTabs_andEmitsSelectionOnTap() { + var selected: TopDest? = null + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + LibreMailBottomBar(current = TopDest.MAILBOX, onSelect = { selected = it }) + } + } + + composeTestRule.onNodeWithText(string(R.string.nav_mailbox)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.nav_settings)).assertIsDisplayed() + + composeTestRule.onNodeWithText(string(R.string.nav_settings)).performClick() + assertEquals(TopDest.SETTINGS, selected) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt new file mode 100644 index 0000000..41780d3 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +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.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for manual IMAP/SMTP account setup. Drives the real [ManualSetupScreen] and a + * real [ManualSetupViewModel] backed by an in-memory [FakeAccountRepository]. + */ +@RunWith(AndroidJUnit4::class) +class ManualSetupScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + // Build the view model once and capture it, so recomposition doesn't recreate it. + private fun setContent( + repository: FakeAccountRepository = FakeAccountRepository(), + onAccountAdded: () -> Unit = {}, + ) { + val viewModel = ManualSetupViewModel(repository) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ManualSetupScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel) + } + } + } + + private fun fillValidForm() { + composeTestRule.onNodeWithText(string(R.string.manual_email)).performTextInput("user@example.com") + composeTestRule.onNodeWithText(string(R.string.manual_password)).performTextInput("app-password") + composeTestRule.onNodeWithText(string(R.string.manual_imap_server)).performTextInput("imap.example.com") + composeTestRule.onNodeWithText(string(R.string.manual_smtp_server)).performTextInput("smtp.example.com") + } + + @Test + fun testAndAddButton_isDisabledUntilRequiredFieldsAreFilled() { + setContent() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsNotEnabled() + fillValidForm() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).assertIsEnabled() + } + + @Test + fun advancedToggle_revealsPortField() { + setContent() + composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertDoesNotExist() + composeTestRule.onNodeWithText(string(R.string.settings_advanced)).performClick() + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithText(string(R.string.manual_imap_port)).fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText(string(R.string.manual_imap_port)).assertExists() + } + + @Test + fun submit_onSuccess_invokesOnAccountAddedWithEnteredCredentials() { + val repository = FakeAccountRepository(result = Result.success(listOf("INBOX"))) + var added = false + setContent(repository) { added = true } + + fillValidForm() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { added } + assertTrue(added) + assertEquals("user@example.com", repository.addedAccount?.email) + assertEquals("app-password", repository.addedPassword) + } + + @Test + fun submit_onFailure_showsErrorAndStaysOnScreen() { + val repository = FakeAccountRepository(result = Result.failure(RuntimeException("Login failed"))) + var added = false + setContent(repository) { added = true } + + fillValidForm() + composeTestRule.onNodeWithText(string(R.string.manual_test_and_add)).performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { + composeTestRule.onAllNodesWithText("Login failed").fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("Login failed").assertExists() + assertFalse(added) + } +} diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt new file mode 100644 index 0000000..063150f --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose + +import android.Manifest +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.SavedStateHandle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.contacts.ContactsRepository +import org.libremail.domain.model.Account +import org.libremail.domain.model.AuthType +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.FakeMailRepository +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for composing and sending a message. Drives the real [ComposeScreen] and a + * real [ComposeViewModel] backed by in-memory fakes plus the real (empty) device contacts provider. + */ +@RunWith(AndroidJUnit4::class) +class ComposeScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val account = Account( + id = "imap:me@example.com", + email = "me@example.com", + displayName = "Me", + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig("imap.example.com", 993, MailSecurity.SSL_TLS), + smtp = ServerConfig("smtp.example.com", 465, MailSecurity.SSL_TLS), + ) + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + @Before + fun grantContactsPermission() { + // ComposeScreen requests READ_CONTACTS on first composition; pre-grant it (before the test + // calls setContent) so no system permission dialog appears to block the headless run. + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.uiAutomation.grantRuntimePermission( + instrumentation.targetContext.packageName, + Manifest.permission.READ_CONTACTS, + ) + } + + // Build the view model once and capture it, so recomposition doesn't recreate it. + private fun setContent( + mailRepository: FakeMailRepository = FakeMailRepository(), + onBack: () -> Unit = {}, + ) { + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + val viewModel = ComposeViewModel( + savedStateHandle = SavedStateHandle(), + mailRepository = mailRepository, + accountRepository = FakeAccountRepository(accounts = listOf(account)), + contactsRepository = ContactsRepository(context), + ) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + ComposeScreen(onBack = onBack, viewModel = viewModel) + } + } + } + + @Test + fun sendButton_isDisabledUntilRecipientEntered() { + setContent() + composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsNotEnabled() + composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com") + composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).assertIsEnabled() + } + + @Test + fun send_withRecipient_sendsMessageAndClosesScreen() { + val mailRepository = FakeMailRepository() + var closed = false + setContent(mailRepository) { closed = true } + + composeTestRule.onNodeWithText(string(R.string.compose_to)).performTextInput("you@example.com") + composeTestRule.onNodeWithText(string(R.string.compose_subject)).performTextInput("Hello") + composeTestRule.onNodeWithText(string(R.string.compose_body)).performTextInput("Body text") + composeTestRule.onNodeWithContentDescription(string(R.string.action_send)).performClick() + + composeTestRule.waitUntil(timeoutMillis = 5_000) { mailRepository.sentMessages.isNotEmpty() } + val sent = mailRepository.sentMessages.single() + assertEquals("you@example.com", sent.to) + assertEquals("Hello", sent.subject) + assertEquals(account.id, sent.accountId) + + composeTestRule.waitUntil(timeoutMillis = 5_000) { closed } + } +} diff --git a/gradlew b/gradlew old mode 100644 new mode 100755