Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
7.7 KiB
Skill: Testing and CI Verification
Description
Guidelines and commands for verifying code changes locally and understanding the Meshtastic-Android CI pipeline. Use this to determine which testing matrix is needed based on the change type.
1) Baseline local verification order
Run in a single invocation for routine changes to ensure code formatting, analysis, and basic compilation:
./gradlew spotlessCheck spotlessApply detekt assembleDebug test allTests
Why no
clean? Incremental builds are safe and significantly faster. Only usecleanwhen debugging stale cache issues.
Why
test allTestsand not justtest: In KMP modules, thetesttask name is ambiguous. Gradle matches bothtestAndroidandtestAndroidHostTestand refuses to run either, silently skipping KMP modules.allTestsis theKotlinTestReportlifecycle task registered by the KMP plugin. Conversely,allTestsdoes not cover pure-Android modules (:androidApp,:core:api, etc.), which is why bothtestandallTestsare needed.
Note: If testing Compose UI on the JVM (Robolectric) with Java 21, pin tests to @Config(sdk = [34]) to avoid SDK 35 compatibility crashes.
SharedFlow + backgroundScope in runTest
When testing long-lived coroutines (e.g., Flow.collect loops launched in backgroundScope), use runTest(UnconfinedTestDispatcher()) instead of plain runTest:
// ❌ BAD — SharedFlow emissions silently never reach collectors
@Test fun `inbound packet is forwarded`() = runTest {
backgroundScope.launch { sut.start(backgroundScope) }
sharedFlow.emit(packet)
// assertion fails — collector never receives the emission
}
// ✅ GOOD — UnconfinedTestDispatcher eagerly dispatches subscriber resumptions
@Test fun `inbound packet is forwarded`() = runTest(UnconfinedTestDispatcher()) {
backgroundScope.launch { sut.start(backgroundScope) }
sharedFlow.emit(packet)
// assertion passes — collector receives emission immediately
}
Why: backgroundScope uses StandardTestDispatcher by default, which does not eagerly dispatch SharedFlow subscriber resumptions. Even advanceUntilIdle() won't trigger delivery. UnconfinedTestDispatcher() fixes this by dispatching eagerly. This affects any test where a coroutine in backgroundScope collects from a SharedFlow or MutableSharedFlow.
2) Change-type verification matrix
docs-onlychanges: Usually no Gradle run required, but runspotlessCheckif practical.UI text/resourcechanges:spotlessCheck,detekt,assembleDebug.feature/commonMain logicchanges:spotlessCheck,detekt,test allTests,assembleDebug.navigation/DI wiringchanges:spotlessCheck,detekt,assembleDebug,test allTests, plus flavor unit tests if available.- If touching any KMP module, also run
kmpSmokeCompile.
- If touching any KMP module, also run
worker/service/backgroundchanges: Broad tests, targeted WorkManager checks.BLE/networking/core repository:spotlessCheck,detekt,assembleDebug,test allTests.
3) Flavor checks
Run these when relevant to map, provider, or flavor-specific behavior:
./gradlew lintFdroidDebug lintGoogleDebug
./gradlew testFdroidDebug testGoogleDebug
4) CI Pipeline Architecture
CI is defined in .github/workflows/reusable-check.yml and structured as four parallel job groups:
lint-check— Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Usesfetch-depth: 0(full clone) for spotless ratcheting and version code calculation. Producescache_read_onlyoutput and computedversion_codefor downstream jobs.test-shards— A 3-shard matrix that runs unit tests in parallel (depends onlint-check):shard-core:allTestsfor allcore:*KMP modules.shard-feature:allTestsfor allfeature:*KMP modules.shard-app: Explicit test tasks for pure-Android/JVM modules (app,desktop,core:barcode). Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. Downstream jobs usefetch-depth: 1and receiveVERSION_CODEfrom lint-check via env var, enabling shallow clones.
android-check— Builds APKs for all flavors (depends onlint-check).build-desktop— Multi-OS matrix (macos-latest,windows-latest,ubuntu-24.04,ubuntu-24.04-arm) that builds desktop distributions viacreateDistributable(depends onlint-check).
Runner Strategy (Three Tiers)
ubuntu-24.04-arm— Lightweight/utility jobs (status checks, labelers, triage, changelog, release metadata, stale, moderation). Benefits from ARM runners' shorter queue times.ubuntu-24.04— Main Gradle-heavy jobs (CIlint-check/test-shards/android-check, release builds, Dokka, publish, dependency-submission). Pin for reproducibility.- Desktop runners: Multi-OS matrix (
macos-latest,windows-latest,ubuntu-24.04,ubuntu-24.04-arm) for thebuild-desktopjob and release packaging.
CI Gradle Properties
gradle.properties is tuned for local dev (8g heap, 4g Kotlin daemon). CI uses .github/ci-gradle.properties, which the gradle-setup composite action copies to ~/.gradle/gradle.properties. Key CI overrides:
org.gradle.daemon=false(single-use runners)kotlin.incremental=false(fresh checkouts)-Xmx4gGradle heap,-Xmx2gKotlin daemon- VFS watching disabled, workers capped at 4
org.gradle.isolated-projects=truefor better parallelism- Disables unused Android build features (
resvalues,shaders)
CI Conventions
- KMP Smoke Compile:
./gradlew kmpSmokeCompileis a lifecycle task (registered inRootConventionPlugin) that auto-discovers all KMP modules and depends on theircompileKotlinJvm+compileKotlinIosSimulatorArm64tasks. maxParallelForksCI logic:ProjectExtensions.ktchecksproject.findProperty("ci") == "true"and uses full available processors in CI (4 forks on std runners) vs. half locally. All CI invocations pass-Pci=true.- Detekt report formats: Detekt.kt checks
project.findProperty("ci") == "true"and disables html, txt, md reports in CI; only xml + sarif are retained for GitHub annotations. - Robolectric SDK caching: The
gradle-setupcomposite action caches~/.m2/repository/org/robolectricto prevent flakySocketExceptionon SDK downloads. Cache key isrobolectric-{version}-sdk{level}— update when bumping version or SDK level. mavenLocal()gated: Disabled by default to prevent CI cache poisoning. Pass-PuseMavenLocalfor local JitPack testing.- JUnit parallel execution: Enabled project-wide with classes running sequentially (
junit.jupiter.execution.parallel.mode.classes.default=same_thread) to avoidDispatchers.setMain()races. Cross-module parallelism comes from Gradle forks (maxParallelForks). test-retryplugin: Applied to all module types (maxRetries=2, maxFailures=10).fail-fast: false: Test sharding does not cancel other shards on failure.- Explicit Gradle task paths: Prefer
app:lintFdroidDebugover shorthandlintDebugin CI. - Pull request CI: Main-only (
.github/workflows/pull-request.ymltargetsmain). - Cache writes: Trusted on
mainand merge queue runs; other refs use read-only cache. - Path filtering:
check-changesinpull-request.ymlmust include module dirs plus build/workflow entrypoints (build-logic/**,gradle/**,.github/workflows/**,gradlew,settings.gradle.kts, etc.). - AboutLibraries: Runs in
offlineModeby default (no GitHub/SPDX API calls). Release builds pass-PaboutLibraries.release=truevia Fastlane/Gradle CLI to enable remote license fetching. Do NOT re-gate onCIorGITHUB_TOKENalone.