diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ef0c692..57cea52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -147,6 +147,12 @@ android { } testOptions { + // Robolectric-backed Compose UI unit tests (issue #373) need the merged Android resources + // (drawables, strings, the compiled resource table) on the JVM unit-test classpath so + // `stringResource(...)` and Material3 theming resolve without an emulator. Off by default in + // AGP; JVM tests that don't touch resources are unaffected. + unitTests.isIncludeAndroidResources = true + // Gradle Managed Devices define the per-API E2E matrix as config-as-code: one virtual // device per supported Android version (a rolling ~7-year window, API 29 → latest stable). // Run the whole matrix with `./gradlew e2eGroupDebugAndroidTest`, or one level with e.g. @@ -251,7 +257,14 @@ val jacocoGeneratedExcludes = listOf( // audit): unlike `jacocoGeneratedExcludes` above, none of this is generated code — it is hand-written // but structurally unreachable from a JVM unit test, so counting it against the metric just measures // how much Compose/framework glue exists rather than how well the logic is tested. Four buckets: -// 1. Compose screen/component render code — only exercisable via a Compose UI test or an emulator. +// 1. Compose screen/component render code. Historically only exercisable via an emulator, so it was +// excluded here. Issue #373 changes that: Robolectric runs the Android framework on the JVM, so a +// `createComposeRule()` test in the `test` source set now gives these files real JVM coverage +// without an emulator. This bucket therefore SHRINKS one screen at a time — each glob is deleted +// in the same PR that adds that screen's Robolectric JVM Compose test. AddAnotherAccountScreen was +// the first (see AddAnotherAccountScreenJvmTest) and has been removed below; the rest are tracked +// as per-area conversion tickets under #373. The coverage-floor re-ratchet is deferred until the +// whole conversion is done and stable (#373) — do NOT raise it in a conversion PR. // 2. Android framework entry points the OS instantiates directly (Activity/Service/Application/ // BackupAgent) rather than the app's own code constructing them. // 3. Hilt DI modules — `@Provides`/`@Binds` one-liners with no branching logic. @@ -298,7 +311,7 @@ val jacocoNonJvmTestableSurface = listOf( "**/AppLockGateHost*", "**/FolderDrawer*", "**/MailboxScreen*", - "**/AddAnotherAccountScreen*", + // AddAnotherAccountScreen converted to a Robolectric JVM Compose test (#373) — now JVM-covered. "**/BatteryOptimizationScreen*", "**/ContactsAccessScreen*", "**/LicenseScreen*", @@ -399,6 +412,45 @@ tasks.named("check") { dependsOn("jacocoTestCoverageVerification") } +// --- Robolectric android-all offline resolution (issue #373) ------------------------------------ +// Robolectric runs the real Android framework on the JVM from a large `android-all-instrumented` +// jar. By default it resolves that jar LAZILY AT TEST TIME by downloading it from Maven Central +// (org.robolectric.internal.dependency.MavenDependencyResolver -> MavenArtifactFetcher). That +// runtime download is unreliable on CI runners and failed the JVM Compose PoC in CI with +// `java.lang.AssertionError at MavenArtifactFetcher ... Caused by: java.io.IOException` ("Failed to +// fetch maven artifact"). Fix: resolve the jar through Gradle instead — reliable, cached, and +// persisted by the CI Gradle cache, using the same repositories as every other dependency — then +// hand it to Robolectric in OFFLINE mode so it never touches the network at test time. +// +// A DEDICATED resolvable configuration (deliberately NOT testImplementation/testRuntimeOnly) keeps +// the ~200 MB instrumented framework jar OFF the JVM unit-test classpath: it must be loaded only by +// Robolectric's sandbox classloader, never flattened onto the app's test classpath where it would +// collide with the stub `android.jar`. `syncRobolectricAndroidAll` stages the resolved jar under +// its Maven filename (android-all-instrumented-.jar) — exactly what Robolectric's +// LocalDependencyResolver looks up as -.jar — and the two system properties +// below switch Robolectric onto that offline directory (see LegacyDependencyResolver). Every +// Robolectric test pins @Config(sdk = 36) (app/src/test/resources/robolectric.properties), so the +// single sdk=36 jar covers them all; a test on a different SDK must add that android-all version to +// this configuration too. The offline properties are inert for non-Robolectric JVM tests. +val robolectricAndroidAll: Configuration = configurations.create("robolectricAndroidAll") { + isCanBeConsumed = false + isCanBeResolved = true +} + +val robolectricDepsDir = layout.buildDirectory.dir("robolectric-android-all") + +val syncRobolectricAndroidAll = tasks.register("syncRobolectricAndroidAll") { + description = "Stages Robolectric's android-all-instrumented jar for offline resolution (issue #373)." + from(robolectricAndroidAll) + into(robolectricDepsDir) +} + +tasks.withType().configureEach { + dependsOn(syncRobolectricAndroidAll) + systemProperty("robolectric.offline", "true") + systemProperty("robolectric.dependency.dir", robolectricDepsDir.get().asFile.absolutePath) +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) @@ -459,6 +511,24 @@ dependencies { // The real org.json for unit tests (android.jar ships a stubbed, no-op version). testImplementation("org.json:json:20231013") + // Robolectric-backed JVM Compose UI tests (issue #373): Robolectric runs the Android framework + // on the JVM so `createComposeRule()` can drive composables without an emulator, bringing screen + // render code into the JaCoCo JVM-testable surface. The Compose test artifacts come from the same + // BOM as the app (aligned versions) and reuse the ui-test-junit4 / ui-test-manifest aliases the + // androidTest source set already declares — here in `test` (JVM), not `androidTest`. Robolectric + // sources Android's real org.json from its sandbox, so it does not clash with the stub-replacing + // org.json above (that is for the plain, non-Robolectric JVM tests). + testImplementation(libs.robolectric) + // The android-all-instrumented framework jar Robolectric loads into its sandbox — resolved via + // Gradle and staged for offline use by syncRobolectricAndroidAll above so no flaky test-time + // download happens in CI (issue #373). On its own dedicated configuration, NOT the test + // classpath — see that block for why. The artifact has no transitive dependencies (verified from + // its POM), so it resolves to exactly the one staged jar. + "robolectricAndroidAll"(libs.robolectric.android.all.instrumented) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.compose.ui.test.manifest) + androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.intents) diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt new file mode 100644 index 0000000..84237bd --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreenJvmTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +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.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric feasibility PoC (issue #373): proves Jetpack Compose UI can be unit-tested on the JVM + * without an emulator. `createComposeRule()` drives [AddAnotherAccountScreen] under + * [RobolectricTestRunner]; `stringResource(...)` and Material3 theming resolve because + * `testOptions.unitTests.isIncludeAndroidResources` is enabled in `app/build.gradle.kts`. Runs under + * `:app:testDebugUnitTest`, NOT `androidTest` — the instrumented `AddAnotherAccountScreenTest` stays. + * + * `@Config(sdk = [36])` because compileSdk/targetSdk 37 (preview) has no Robolectric 4.16 sandbox + * (also the default in `src/test/resources/robolectric.properties`); `@GraphicsMode(NATIVE)` is + * required for Compose to render under Robolectric. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [36]) +class AddAnotherAccountScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + private fun setContent(onAddAnother: () -> Unit = {}, onFinish: () -> Unit = {}) { + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + AddAnotherAccountScreen(onAddAnother = onAddAnother, onFinish = onFinish) + } + } + } + + @Test + fun rendersAccountAddedTitleFromResources() { + setContent() + + // Proves the merged Android resource table loaded on the JVM (isIncludeAndroidResources). + composeTestRule.onNodeWithText(string(R.string.onboarding_account_added_title)).assertIsDisplayed() + } + + @Test + fun tappingYes_invokesOnAddAnother() { + var addAnother = false + setContent(onAddAnother = { addAnother = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_yes)).performClick() + + assertTrue(addAnother) + } + + @Test + fun tappingNo_invokesOnFinish() { + var finished = false + setContent(onFinish = { finished = true }) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + assertTrue(finished) + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..eb85a88 --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# Default SDK for every Robolectric (JVM) test (issue #373). compileSdk/targetSdk are 37, a +# preview level Robolectric 4.16.x does not ship a sandbox for; 36 (Baklava) is its newest +# supported level and needs JDK 21 (the project's Gradle JDK). Individual tests may still override +# with @Config(sdk = [..]). Only affects tests run with RobolectricTestRunner; plain JUnit unit +# tests are unaffected. +sdk=36 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f61fb86..baf07e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,22 @@ androidxTestRules = "1.7.0" turbine = "1.2.1" mockk = "1.14.11" greenmail = "2.1.9" +# Robolectric — runs the Android framework on the JVM so Compose UI (createComposeRule) can be +# unit-tested without an emulator (issue #373). 4.16.x is the first line to support compileSdk 36 +# (Baklava) and requires JDK 21 for it; API 37 (preview) is unsupported, so Robolectric tests pin +# @Config(sdk = 36) — also set in app/src/test/resources/robolectric.properties. +robolectric = "4.16.1" +# The instrumented android-all runtime jar Robolectric loads into its sandbox to run the Android +# framework on the JVM. By DEFAULT Robolectric downloads this ~200 MB artifact from Maven Central +# lazily at TEST time (MavenDependencyResolver -> MavenArtifactFetcher); that runtime fetch is +# unreliable on CI runners and failed the JVM Compose PoC with "Failed to fetch maven artifact" +# (issue #373). Pinning it lets Gradle resolve+cache it and hand it to Robolectric in offline mode +# (see the robolectricAndroidAll wiring in app/build.gradle.kts) so no network fetch happens at +# test time. The value is BOTH Robolectric-version- and SDK-specific: it is exactly what +# Robolectric 4.16.1's DefaultSdkProvider maps @Config(sdk = 36) to — androidVersion "16", build +# "13921718", preinstrumented suffix "-i7". Bump it together with `robolectric` above, and update +# it if the pinned @Config(sdk = ..) / robolectric.properties SDK ever changes. +robolectricAndroidAll = "16-robolectric-13921718-i7" errorprone = "2.50.0" # Lint/format. ktlint owns formatting; detekt owns static analysis (formatting ruleset off). # detekt 2.0 is the only line with Gradle 9 support (stable 1.23.x tops out at Gradle 8.12). @@ -115,6 +131,11 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } # mockk's Android artifact for instrumented tests (dexmaker/byte-buddy-android backed). mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } +# Robolectric — JVM Android framework for unit-testing Compose UI without an emulator (issue #373). +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +# Resolved via Gradle (not Robolectric's runtime Maven fetch) and staged for offline use so the JVM +# Compose tests run in CI without a flaky test-time download — see app/build.gradle.kts (issue #373). +robolectric-android-all-instrumented = { group = "org.robolectric", name = "android-all-instrumented", version.ref = "robolectricAndroidAll" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" }