diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bce1769..57cea52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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-.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) @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6039f7b..baf07e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }