Merge branch 'main' into ci-389-sdk-setup-hardening
This commit is contained in:
+72
-2
@@ -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-<version>.jar) — exactly what Robolectric's
|
||||
// LocalDependencyResolver looks up as <artifactId>-<version>.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<Sync>("syncRobolectricAndroidAll") {
|
||||
description = "Stages Robolectric's android-all-instrumented jar for offline resolution (issue #373)."
|
||||
from(robolectricAndroidAll)
|
||||
into(robolectricDepsDir)
|
||||
}
|
||||
|
||||
tasks.withType<Test>().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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user