Skill v1.0.1
Automated scan100/100+3 new
version: "1.0.1"
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 spotlessApply spotlessCheck detekt assembleDebug test allTests
Why no `clean`? Incremental builds are safe and significantly faster. Only usecleanwhen debugging stale cache issues.
Why `test allTests` and not just `test`: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:barcode, etc.), which is why bothtestandallTestsare needed.
Note: If testing Compose UI on the JVM (Robolectric), 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. 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
3b) Screenshot testing (two modules)
Compose Preview Screenshot Testing (AGP/layoutlib) is split into two modules — keep the distinction:
- `:screenshot-tests` — visual-regression gate. CI runs
:screenshot-tests:validateDebugScreenshotTest. Holds atomic, dual-purpose components. Touching one of these previews is expected to move a gated baseline. - `:docs-screenshots` — generate-only, NOT validated in CI. Holds doc-framed compositions (crops/full screens tuned for the docs site). Reframe these freely; it never churns the regression gate.
./gradlew :screenshot-tests:updateDebugScreenshotTest # regression goldens./gradlew :docs-screenshots:updateDebugScreenshotTest # doc-framed composition images./gradlew :screenshot-tests:copyDocsScreenshots # copy doc images from BOTH modules → docs/assets
Rendering is host-deterministic (layoutlib): a local update produces references byte-identical to CI, so locally-recorded goldens pass validate. Exception — colour emoji: do NOT gate CI on them. Layoutlib bundles the text fonts but resolves colour emoji through the host's emoji font, so glyph edges rasterise differently on macOS than on the Linux runner. Layout, text and vectors still match exactly; only the emoji anti-aliasing moves, which is enough to blow the 0.0005 imageDifferenceThreshold on an emoji-dense composition and cannot be fixed by re-running update locally (PR #6631). Assert the layout rule in a unit test instead — see core/ui/src/commonTest/.../emoji/EmojiCellSizeTest.kt — or put the composition in generate-only :docs-screenshots. copyDocsScreenshots overwrites a stale committed nodes_detail_local.png each run — git checkout it. Public previews consumed cross-module by a wrapper need a detekt-baseline.xml entry (PreviewPublic). New screenshot? Pick the module by purpose; see docs/assets/screenshots/README.md.
3c) Fresh-install manual/agent testing: skip onboarding
Debug builds accept an intent extra to skip the intro flow (MainActivity.kt, BuildConfig.DEBUG-gated — never reaches release/Play builds). Pair with pm grant (native Android, no app code) to pre-accept runtime permissions:
adb shell pm grant <pkg> android.permission.BLUETOOTH_SCANadb shell pm grant <pkg> android.permission.BLUETOOTH_CONNECTadb shell pm grant <pkg> android.permission.ACCESS_FINE_LOCATIONadb shell pm grant <pkg> android.permission.POST_NOTIFICATIONS # API 33+adb shell am start -n <pkg>/org.meshtastic.app.MainActivity --ez skip_onboarding true
Use this whenever driving the app from a fresh install/uninstall (screenshot tests, UI automation, agent-driven exploration) instead of clicking through the intro screens.
4) CI Pipeline Architecture
CI is defined in .github/workflows/reusable-check.yml and structured as parallel job groups:
- `lint-check` — Runs spotless, detekt, Android lint, and KMP smoke compile in a single Gradle invocation (avoids 3x cold-start overhead). Uses
fetch-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 on
lint-check):
shard-core:allTestsfor allcore:*KMP modules.shard-feature:allTestsfor allfeature:*KMP modules.shard-app: Explicit test tasks for pure-Android/JVM modules (androidApp,desktopApp,core:barcode).
Each shard generates Kover XML coverage and uploads test results + coverage to Codecov with per-shard flags. Downstream jobs use fetch-depth: 1 and receive VERSION_CODE from lint-check via env var, enabling shallow clones.
- `android-check` — Builds APKs for all flavors (depends on
lint-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). - `screenshot-check` — Runs
:screenshot-tests:validateDebugScreenshotTest(the visual-regression gate) and uploads a diff report. Note::docs-screenshotsis intentionally NOT validated here (generate-only).
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 (CI
lint-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. - `maxParallelForks` CI 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 retry: Develocity plugin's native retry (
develocity.testRetryon each Test task), configured inProjectExtensions.kt(maxRetries=2, maxFailures=10). Screenshot tests opt out (maxRetries=0). The standaloneorg.gradle.test-retryplugin was removed. - `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). - Merge queue hygiene:
merge-queue.ymlcancels superseded runs for the same PR (GitHub does not auto-cancel destroyed merge-group runs) and skips the heavy pipeline for docs-only entries (docs/**,*.md).rb-checkruns ONLY in the merge queue.main-check.ymlpassesrun_lint: false— every main commit is a merge-queue-verified merge commit, so main pushes only rebuild the debug APKs for the snapshot release. - Cache writes: Trusted on
mainonly; merge-queue cache scopes are throwaway branches (writes unrecoverable), so the queue reads only, like all other refs. - 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.