fix(test): resolve Robolectric android-all via Gradle so the JVM Compose test runs in CI (#373)

Robolectric resolved its android-all-instrumented runtime jar lazily at test
time via its own MavenDependencyResolver/MavenArtifactFetcher, and that
download is unreliable on CI runners: AddAnotherAccountScreenJvmTest failed
with `AssertionError at MavenArtifactFetcher ... IOException` ("Failed to
fetch maven artifact"), though it passed locally where ~/.m2 was warm.

Resolve the jar through Gradle instead (reliable, cached, persisted by the CI
Gradle cache) and hand it to Robolectric in offline mode so it never hits the
network at test time:
- Pin org.robolectric:android-all-instrumented:16-robolectric-13921718-i7
  (exactly what Robolectric 4.16.1 DefaultSdkProvider maps @Config(sdk=36) to)
  in the version catalog.
- Add it to a dedicated resolvable configuration (NOT testImplementation/
  testRuntimeOnly, which would flatten the ~200MB instrumented framework onto
  the JVM test classpath and collide with the stub android.jar).
- syncRobolectricAndroidAll stages the jar under its Maven filename, and
  robolectric.offline + robolectric.dependency.dir point Robolectric's
  LocalDependencyResolver at it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 15:49:07 -05:00
co-authored by Claude Opus 4.8
parent cf1a8f6b83
commit 324f7c2c51
2 changed files with 59 additions and 0 deletions
+45
View File
@@ -412,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)
@@ -480,6 +519,12 @@ dependencies {
// 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)
+14
View File
@@ -35,6 +35,17 @@ greenmail = "2.1.9"
# (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).
@@ -122,6 +133,9 @@ mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "moc
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" }