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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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<Account> = emptyList(),
|
||||
private var result: Result<List<String>> = 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<List<Account>> = accountsFlow
|
||||
|
||||
override suspend fun testConnection(params: ImapConnectionParams): Result<List<String>> = result
|
||||
|
||||
override suspend fun addImapAccount(account: Account, password: String): Result<List<String>> {
|
||||
addedAccount = account
|
||||
addedPassword = password
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun addOutlookAccount(
|
||||
email: String,
|
||||
accessToken: String,
|
||||
authStateJson: String,
|
||||
): Result<List<String>> = 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<Unit> = Result.success(Unit),
|
||||
) : MailRepository {
|
||||
|
||||
val sentMessages = mutableListOf<OutgoingMessage>()
|
||||
val savedDrafts = mutableListOf<Draft>()
|
||||
val deletedDraftIds = mutableListOf<String>()
|
||||
|
||||
override fun observeMessages(): Flow<List<Message>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun getMessage(id: String): Message? = null
|
||||
|
||||
override suspend fun openMessage(id: String): Result<Message> =
|
||||
Result.failure(UnsupportedOperationException("not used in UI tests"))
|
||||
|
||||
override fun observeAttachments(messageId: String): Flow<List<Attachment>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun downloadAttachment(messageId: String, partIndex: Int): Result<File> =
|
||||
Result.failure(UnsupportedOperationException("not used in UI tests"))
|
||||
|
||||
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun deleteMessage(id: String): Result<Unit> = Result.success(Unit)
|
||||
|
||||
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> {
|
||||
sentMessages += outgoing
|
||||
return sendResult
|
||||
}
|
||||
|
||||
override fun observeDrafts(): Flow<List<Draft>> = 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<List<OutboxMessage>> = flowOf(emptyList())
|
||||
|
||||
override suspend fun cancelOutboxMessage(id: String) {}
|
||||
|
||||
override suspend fun retryOutbox() {}
|
||||
|
||||
override suspend fun searchServer(query: String) {}
|
||||
|
||||
override suspend fun clearSearchResults() {}
|
||||
}
|
||||
@@ -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<ComponentActivity>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ComponentActivity>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<ComponentActivity>()
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user