From a11dee42a707b024033e16b802f014966aad0b89 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Sun, 12 Apr 2026 15:20:00 -0500
Subject: [PATCH] test: migrate Compose UI tests from androidTest to commonTest
(#5091)
---
.github/workflows/codeql.yml | 108 ------------------
.github/workflows/main-check.yml | 2 -
.github/workflows/merge-queue.yml | 2 -
.github/workflows/pull-request.yml | 8 +-
.github/workflows/reusable-check.yml | 96 +---------------
.skills/code-review/SKILL.md | 1 +
.skills/testing-ci/SKILL.md | 7 +-
app/build.gradle.kts | 6 -
.../filter/MessageFilterIntegrationTest.kt | 48 --------
core/ble/build.gradle.kts | 7 --
core/ui/build.gradle.kts | 4 +-
.../core/ui/component/AlertHostTest.kt | 34 ++++--
.../core/ui/component/ImportFabUiTest.kt | 56 +++++----
.../core/ui/util/AlertManagerUiTest.kt | 38 +++---
docs/decisions/architecture-review-2026-03.md | 18 +--
feature/firmware/build.gradle.kts | 8 --
feature/intro/build.gradle.kts | 7 --
feature/map/build.gradle.kts | 7 --
feature/messaging/build.gradle.kts | 5 +-
.../messaging/component/MessageItemTest.kt | 34 +++---
feature/node/build.gradle.kts | 9 --
feature/settings/build.gradle.kts | 17 +--
.../component/MapReportingPreferenceTest.kt | 98 ----------------
.../settings/debugging/DebugSearchTest.kt | 76 ++++++------
.../component/EditDeviceProfileDialogTest.kt | 97 ++++++++--------
.../component/MapReportingPreferenceTest.kt | 99 ++++++++++++++++
gradle/libs.versions.toml | 1 +
27 files changed, 296 insertions(+), 597 deletions(-)
delete mode 100644 .github/workflows/codeql.yml
delete mode 100644 app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt (54%)
rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt (63%)
rename core/ui/src/{androidTest => commonTest}/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt (61%)
rename feature/messaging/src/{androidTest => commonTest}/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt (81%)
delete mode 100644 feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt
rename feature/settings/src/{androidHostTest => commonTest}/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt (71%)
rename feature/settings/src/{androidTest => commonTest}/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt (54%)
create mode 100644 feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
deleted file mode 100644
index e67a217c7..000000000
--- a/.github/workflows/codeql.yml
+++ /dev/null
@@ -1,108 +0,0 @@
-# For most projects, this workflow file will not need changing; you simply need
-# to commit it to your repository.
-#
-# You may wish to alter this file to override the set of languages analyzed,
-# or to provide custom queries or build logic.
-#
-# ******** NOTE ********
-# We have attempted to detect the languages in your repository. Please check
-# the `language` matrix defined below to confirm you have the correct set of
-# supported CodeQL languages.
-#
-name: "CodeQL Advanced"
-
-on:
- # push:
- # branches: [ "main" ]
- # pull_request:
- # branches: [ "main" ]
- schedule:
- - cron: '0 0 * * 0'
- workflow_dispatch:
-
-jobs:
- analyze:
- name: Analyze (${{ matrix.language }})
- # Runner size impacts CodeQL analysis time. To learn more, please see:
- # - https://gh.io/recommended-hardware-resources-for-running-codeql
- # - https://gh.io/supported-runners-and-hardware-resources
- # - https://gh.io/using-larger-runners (GitHub.com only)
- # Consider using larger runners or machines with greater resources for possible analysis time improvements.
- runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-24.04' }}
- if: github.repository == 'meshtastic/Meshtastic-Android'
- permissions:
- # required for all workflows
- security-events: write
-
- # required to fetch internal or private CodeQL packs
- packages: read
-
- # only required for workflows in private repositories
- actions: read
- contents: read
-
- strategy:
- fail-fast: false
- matrix:
- include:
- - language: actions
- build-mode: none
- - language: java-kotlin
- build-mode: autobuild
- # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
- # Use `c-cpp` to analyze code written in C, C++ or both
- # Use 'java-kotlin' to analyze code written in Java, Kotlin or both
- # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
- # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
- # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
- # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
- # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
- steps:
- - name: Checkout repository
- uses: actions/checkout@v6
-
- # Add any setup steps before running the `github/codeql-action/init` action.
- # This includes steps like installing compilers or runtimes (`actions/setup-node`
- # or others). This is typically only required for manual builds.
- # - name: Setup runtime (example)
- # uses: actions/setup-example@v1
- - name: Java Setup
- uses: actions/setup-java@v5
- with:
- distribution: 'temurin' # See 'Supported distributions' for available options
- java-version: '21'
- token: ${{ github.token }}
-
- # Initializes the CodeQL tools for scanning.
- - name: Initialize CodeQL
- uses: github/codeql-action/init@v4
- with:
- languages: ${{ matrix.language }}
- build-mode: ${{ matrix.build-mode }}
- # If you wish to specify custom queries, you can do so here or in a config file.
- # By default, queries listed here will override any specified in a config file.
- # Prefix the list here with "+" to use these queries and those in the config file.
-
- # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
- # queries: security-extended,security-and-quality
-
- # If the analyze step fails for one of the languages you are analyzing with
- # "We were unable to automatically build your code", modify the matrix above
- # to set the build mode to "manual" for that language. Then modify this step
- # to build your code.
- # âšī¸ Command-line programs to run using the OS shell.
- # đ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- - if: matrix.build-mode == 'manual'
- shell: bash
- run: |
- echo 'If you are using a "manual" build mode for one or more of the' \
- 'languages you are analyzing, replace this with the commands to build' \
- 'your code, for example:'
- echo ' make bootstrap'
- echo ' make release'
- exit 1
-
- - name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@v4
- with:
- category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/main-check.yml b/.github/workflows/main-check.yml
index 4ef967dfc..eaf3f54d3 100644
--- a/.github/workflows/main-check.yml
+++ b/.github/workflows/main-check.yml
@@ -21,8 +21,6 @@ jobs:
with:
run_lint: true
run_unit_tests: false
- run_instrumented_tests: false
run_desktop_builds: false
- api_levels: '[35]'
upload_artifacts: true
secrets: inherit
diff --git a/.github/workflows/merge-queue.yml b/.github/workflows/merge-queue.yml
index 2818ca939..44d31183d 100644
--- a/.github/workflows/merge-queue.yml
+++ b/.github/workflows/merge-queue.yml
@@ -18,8 +18,6 @@ jobs:
with:
run_lint: true
run_unit_tests: true
- run_instrumented_tests: true
- api_levels: '[26, 35]' # Comprehensive testing for Merge Queue
upload_artifacts: false
secrets: inherit
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index 7c2ea7f50..22a611576 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -99,9 +99,9 @@ jobs:
PY
# 2. VALIDATION & BUILD: Delegate to reusable-check.yml
- # We disable instrumented tests, coverage, and desktop builds for PRs to keep
- # feedback fast (< 10 mins). Desktop compilation is already covered by the
- # :desktop:test task in the shard-app test shard.
+ # We disable coverage and desktop builds for PRs to keep feedback fast
+ # (< 10 mins). Desktop compilation is already covered by the :desktop:test
+ # task in the shard-app test shard.
validate-and-build:
needs: check-changes
if: needs.check-changes.outputs.android == 'true'
@@ -109,10 +109,8 @@ jobs:
with:
run_lint: true
run_unit_tests: true
- run_instrumented_tests: false
run_coverage: false
run_desktop_builds: false
- api_levels: '[35]'
upload_artifacts: true
secrets: inherit
diff --git a/.github/workflows/reusable-check.yml b/.github/workflows/reusable-check.yml
index 8e310e9ac..26dbe7685 100644
--- a/.github/workflows/reusable-check.yml
+++ b/.github/workflows/reusable-check.yml
@@ -9,15 +9,9 @@ on:
run_unit_tests:
type: boolean
default: true
- run_instrumented_tests:
- type: boolean
- default: true
run_coverage:
type: boolean
default: true
- api_levels:
- type: string
- default: '[35]'
run_desktop_builds:
type: boolean
default: true
@@ -238,7 +232,7 @@ jobs:
**/build/test-results
retention-days: 7
- # ââ Android Build & Instrumented Tests ââââââââââââââââââââââââââââââ
+ # ââ Android Build ââââââââââââââââââââââââââââââââââââââââââââââââââââ
android-check:
runs-on: ubuntu-24.04
permissions:
@@ -247,10 +241,6 @@ jobs:
needs: lint-check
env:
VERSION_CODE: ${{ needs.lint-check.outputs.version_code }}
- strategy:
- fail-fast: true
- matrix:
- api_level: ${{ fromJson(inputs.api_levels) }}
steps:
- name: Checkout code
@@ -265,99 +255,25 @@ jobs:
gradle_encryption_key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}
cache_read_only: ${{ needs.lint-check.outputs.cache_read_only }}
- - name: Determine matrix metadata
- id: matrix_meta
- shell: bash
- run: |
- first_api=$(python3 - <<'PY'
- import json
- print(json.loads('${{ inputs.api_levels }}')[0])
- PY
- )
-
- if [[ "${{ matrix.api_level }}" == "$first_api" ]]; then
- echo "is_first_api=true" >> "$GITHUB_OUTPUT"
- else
- echo "is_first_api=false" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Determine Android tasks
- id: tasks
- shell: bash
- run: |
- tasks=(
- "app:assembleFdroidDebug"
- "app:assembleGoogleDebug"
- )
-
- if [[ "${{ inputs.run_instrumented_tests }}" == "true" ]]; then
- tasks+=(
- "app:connectedFdroidDebugAndroidTest"
- "app:connectedGoogleDebugAndroidTest"
- )
- fi
-
- printf 'tasks=%s\n' "${tasks[*]}" >> "$GITHUB_OUTPUT"
-
- - name: Enable KVM group perms
- if: inputs.run_instrumented_tests == true
- run: |
- echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
- sudo udevadm control --reload-rules
- sudo udevadm trigger --name-match=kvm
-
- - name: Run Android Build & Instrumented Tests
- if: inputs.run_instrumented_tests == true
- uses: reactivecircus/android-emulator-runner@v2
- with:
- api-level: ${{ matrix.api_level }}
- arch: x86_64
- force-avd-creation: false
- emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
- disable-animations: true
- script: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
-
- - name: Run Android Build
- if: inputs.run_instrumented_tests == false
- run: ./gradlew ${{ steps.tasks.outputs.tasks }} -Pci=true --parallel --configuration-cache --continue --scan
-
- - name: Upload instrumented test results to Codecov
- if: ${{ !cancelled() && inputs.run_instrumented_tests && steps.matrix_meta.outputs.is_first_api == 'true' }}
- uses: codecov/codecov-action@v6
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
- slug: meshtastic/Meshtastic-Android
- flags: android-instrumented
- fail_ci_if_error: false
- report_type: test_results
- files: "**/build/outputs/androidTest-results/**/*.xml"
+ - name: Build Android APKs
+ run: ./gradlew app:assembleFdroidDebug app:assembleGoogleDebug -Pci=true --parallel --configuration-cache --continue --scan
- name: Upload debug artifact
- if: ${{ steps.matrix_meta.outputs.is_first_api == 'true' && inputs.upload_artifacts }}
+ if: ${{ inputs.upload_artifacts }}
uses: actions/upload-artifact@v7
with:
name: app-debug-apks
path: app/build/outputs/apk/*/debug/*.apk
- retention-days: 14
+ retention-days: 7
- name: Report App Size
- if: ${{ always() && steps.matrix_meta.outputs.is_first_api == 'true' }}
+ if: always()
run: |
echo "### App Size Report" >> $GITHUB_STEP_SUMMARY
echo "| Artifact | Size |" >> $GITHUB_STEP_SUMMARY
echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY
find app/build/outputs/apk -name "*.apk" -exec du -h {} + | awk '{print "| " $2 " | " $1 " |"}' >> $GITHUB_STEP_SUMMARY
- - name: Upload Android reports
- if: ${{ always() && inputs.upload_artifacts }}
- uses: actions/upload-artifact@v7
- with:
- name: reports-android-api-${{ matrix.api_level }}
- path: |
- **/build/outputs/androidTest-results
- retention-days: 7
- if-no-files-found: ignore
-
# ââ Desktop Build âââââââââââââââââââââââââââââââââââââââââââââââââââ
build-desktop:
name: Build Desktop Debug (${{ matrix.os }})
diff --git a/.skills/code-review/SKILL.md b/.skills/code-review/SKILL.md
index 08caa95be..dce08761d 100644
--- a/.skills/code-review/SKILL.md
+++ b/.skills/code-review/SKILL.md
@@ -56,6 +56,7 @@ When reviewing code, meticulously verify the following categories. Flag any devi
- [ ] **Compose Multiplatform:** Ensure `compose-multiplatform-*` aliases are used instead of plain `androidx.compose` in all KMP modules.
### 7. Testing
+- [ ] **Test Placement:** New Compose UI tests must go in `commonTest` using `runComposeUiTest {}` + `kotlin.test.Test`. Do not add `androidTest` (instrumented) tests.
- [ ] **Shared Test Utilities:** Test fakes, doubles, and utilities should be placed in `core:testing`.
- [ ] **Libraries:** Verify usage of `Turbine` for Flow testing, `Kotest` for property-based testing, and `Mokkery` for mocking.
- [ ] **Robolectric Configuration:** Check that Compose UI tests running via Robolectric on JVM are pinned to `@Config(sdk = [34])` to prevent Java 21 / SDK 35 compatibility issues.
diff --git a/.skills/testing-ci/SKILL.md b/.skills/testing-ci/SKILL.md
index 8342714de..586c1ef9c 100644
--- a/.skills/testing-ci/SKILL.md
+++ b/.skills/testing-ci/SKILL.md
@@ -34,14 +34,13 @@ Run in this order for routine changes to ensure code formatting, analysis, and b
- `worker/service/background` changes: Broad tests, targeted WorkManager checks.
- `BLE/networking/core repository`: `spotlessCheck`, `detekt`, `assembleDebug`, `test allTests`.
-## 3) Flavor and instrumentation checks
+## 3) Flavor checks
Run these when relevant to map, provider, or flavor-specific behavior:
```bash
./gradlew lintFdroidDebug lintGoogleDebug
./gradlew testFdroidDebug testGoogleDebug
-./gradlew connectedAndroidTest
```
## 4) CI Pipeline Architecture
@@ -55,12 +54,12 @@ CI is defined in `.github/workflows/reusable-check.yml` and structured as four p
- `shard-app`: Explicit test tasks for pure-Android/JVM modules (`app`, `desktop`, `core:barcode`, `mesh_service_example`).
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.
-3. **`android-check`** â Builds APKs and runs instrumented tests (depends on `lint-check`).
+3. **`android-check`** â Builds APKs for all flavors (depends on `lint-check`).
4. **`build-desktop`** â Multi-OS matrix (`macos-latest`, `windows-latest`, `ubuntu-24.04`, `ubuntu-24.04-arm`) that builds desktop distributions via `createDistributable` (depends on `lint-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 (CI `lint-check`/`test-shards`/`android-check`, release builds, Dokka, CodeQL, publish, dependency-submission). Pin for reproducibility.
+- **`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 the `build-desktop` job and release packaging.
### CI Gradle Properties
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ed9f3a766..1c8ed4c39 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -297,12 +297,6 @@ dependencies {
fdroidImplementation(libs.osmdroid.geopackage) { exclude(group = "com.j256.ormlite") }
fdroidImplementation(libs.osmbonuspack)
- androidTestImplementation(libs.androidx.test.runner)
- androidTestImplementation(libs.androidx.test.ext.junit)
- androidTestImplementation(libs.kotlinx.coroutines.test)
- androidTestImplementation(libs.androidx.compose.ui.test.junit4)
- androidTestImplementation(libs.koin.test)
-
testImplementation(kotlin("test-junit"))
testImplementation(libs.androidx.work.testing)
testImplementation(libs.koin.test)
diff --git a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt b/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
deleted file mode 100644
index 4cbf88356..000000000
--- a/app/src/androidTest/kotlin/org/meshtastic/app/filter/MessageFilterIntegrationTest.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.app.filter
-
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import kotlinx.coroutines.test.runTest
-import org.junit.Assert.assertTrue
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.koin.test.KoinTest
-import org.koin.test.inject
-import org.meshtastic.core.repository.FilterPrefs
-import org.meshtastic.core.repository.MessageFilter
-
-@RunWith(AndroidJUnit4::class)
-class MessageFilterIntegrationTest : KoinTest {
-
- private val filterPrefs: FilterPrefs by inject()
-
- private val filterService: MessageFilter by inject()
-
- @org.junit.Ignore("Flaky integration test, needs Koin test rule setup")
- @Test
- fun filterPrefsIntegration() = runTest {
- filterPrefs.setFilterEnabled(true)
- filterPrefs.setFilterWords(setOf("test", "spam"))
- // Wait briefly for DataStore to process the writes and flows to emit
- kotlinx.coroutines.delay(100)
- filterService.rebuildPatterns()
-
- assertTrue(filterService.shouldFilter("this is a test message"))
- assertTrue(filterService.shouldFilter("spam content"))
- }
-}
diff --git a/core/ble/build.gradle.kts b/core/ble/build.gradle.kts
index d26431634..f270e6aa3 100644
--- a/core/ble/build.gradle.kts
+++ b/core/ble/build.gradle.kts
@@ -50,12 +50,5 @@ kotlin {
implementation(libs.kotlinx.coroutines.test)
implementation(projects.core.testing)
}
-
- val androidHostTest by getting {
- dependencies {
- implementation(libs.junit)
- implementation(libs.androidx.lifecycle.testing)
- }
- }
}
}
diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts
index bbe3204e5..76475e096 100644
--- a/core/ui/build.gradle.kts
+++ b/core/ui/build.gradle.kts
@@ -26,7 +26,6 @@ kotlin {
android {
namespace = "org.meshtastic.core.ui"
androidResources.enable = false
- withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
@@ -70,8 +69,9 @@ kotlin {
implementation(projects.core.testing)
implementation(libs.junit)
implementation(libs.kotlinx.coroutines.test)
+ implementation(libs.compose.multiplatform.ui.test)
}
- val androidHostTest by getting { dependencies { implementation(libs.androidx.test.runner) } }
+ jvmTest.dependencies { implementation(compose.desktop.currentOs) }
}
}
diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt
similarity index 54%
rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt
rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt
index b6abd64b0..ab0f1a80f 100644
--- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AlertHostTest.kt
@@ -16,28 +16,46 @@
*/
package org.meshtastic.core.ui.component
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
-import org.junit.Rule
-import org.junit.Test
+import androidx.compose.ui.test.runComposeUiTest
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
import org.meshtastic.core.ui.util.AlertManager
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class)
class AlertHostTest {
- @get:Rule val composeTestRule = createComposeRule()
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
@Test
- fun alertHost_showsDialog_whenAlertIsTriggered() {
+ fun alertHost_showsDialog_whenAlertIsTriggered() = runComposeUiTest {
val alertManager = AlertManager()
val title = "Alert Title"
val message = "Alert Message"
- composeTestRule.setContent { AlertHost(alertManager = alertManager) }
+ setContent { AlertHost(alertManager = alertManager) }
alertManager.showAlert(title = title, message = message)
- composeTestRule.onNodeWithText(title).assertIsDisplayed()
- composeTestRule.onNodeWithText(message).assertIsDisplayed()
+ onNodeWithText(title).assertIsDisplayed()
+ onNodeWithText(message).assertIsDisplayed()
}
}
diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt
similarity index 63%
rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt
rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt
index cc4f32b8e..650671de2 100644
--- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/ImportFabUiTest.kt
@@ -18,27 +18,25 @@ package org.meshtastic.core.ui.component
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.ui.test.assertDoesNotExist
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
-import org.junit.Rule
-import org.junit.Test
+import androidx.compose.ui.test.runComposeUiTest
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalNfcScannerSupported
import org.meshtastic.proto.SharedContact
import org.meshtastic.proto.User
+import kotlin.test.Test
+@OptIn(ExperimentalTestApi::class)
class ImportFabUiTest {
- @get:Rule val composeTestRule = createComposeRule()
-
@Test
- fun importFab_expands_onButtonClick_whenSupported() {
+ fun importFab_expands_onButtonClick_whenSupported() = runComposeUiTest {
val testTag = "import_fab"
- composeTestRule.setContent {
+ setContent {
CompositionLocalProvider(
LocalBarcodeScannerSupported provides true,
LocalNfcScannerSupported provides true,
@@ -48,18 +46,18 @@ class ImportFabUiTest {
}
// Expand the FAB
- composeTestRule.onNodeWithTag(testTag).performClick()
+ onNodeWithTag(testTag).performClick()
// Verify menu items are visible using their tags
- composeTestRule.onNodeWithTag("nfc_import").assertIsDisplayed()
- composeTestRule.onNodeWithTag("qr_import").assertIsDisplayed()
- composeTestRule.onNodeWithTag("url_import").assertIsDisplayed()
+ onNodeWithTag("nfc_import").assertIsDisplayed()
+ onNodeWithTag("qr_import").assertIsDisplayed()
+ onNodeWithTag("url_import").assertIsDisplayed()
}
@Test
- fun importFab_hidesNfcAndQr_whenNotSupported() {
+ fun importFab_hidesNfcAndQr_whenNotSupported() = runComposeUiTest {
val testTag = "import_fab"
- composeTestRule.setContent {
+ setContent {
CompositionLocalProvider(
LocalBarcodeScannerSupported provides false,
LocalNfcScannerSupported provides false,
@@ -69,41 +67,41 @@ class ImportFabUiTest {
}
// Expand the FAB
- composeTestRule.onNodeWithTag(testTag).performClick()
+ onNodeWithTag(testTag).performClick()
// Verify menu items are visible using their tags
- composeTestRule.onNodeWithTag("nfc_import").assertDoesNotExist()
- composeTestRule.onNodeWithTag("qr_import").assertDoesNotExist()
- composeTestRule.onNodeWithTag("url_import").assertIsDisplayed()
+ onNodeWithTag("nfc_import").assertDoesNotExist()
+ onNodeWithTag("qr_import").assertDoesNotExist()
+ onNodeWithTag("url_import").assertIsDisplayed()
}
@Test
- fun importFab_showsUrlDialog_whenUrlItemClicked() {
+ fun importFab_showsUrlDialog_whenUrlItemClicked() = runComposeUiTest {
val testTag = "import_fab"
- composeTestRule.setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) }
+ setContent { MeshtasticImportFAB(onImport = {}, isContactContext = true, testTag = testTag) }
- composeTestRule.onNodeWithTag(testTag).performClick()
- composeTestRule.onNodeWithTag("url_import").performClick()
+ onNodeWithTag(testTag).performClick()
+ onNodeWithTag("url_import").performClick()
// The URL dialog should be shown.
// We'll search for its title indirectly or check if an AlertDialog appeared.
}
@Test
- fun importFab_showsShareChannels_whenCallbackProvided() {
+ fun importFab_showsShareChannels_whenCallbackProvided() = runComposeUiTest {
val testTag = "import_fab"
- composeTestRule.setContent {
+ setContent {
MeshtasticImportFAB(onImport = {}, onShareChannels = {}, isContactContext = false, testTag = testTag)
}
- composeTestRule.onNodeWithTag(testTag).performClick()
- composeTestRule.onNodeWithTag("share_channels").assertIsDisplayed()
+ onNodeWithTag(testTag).performClick()
+ onNodeWithTag("share_channels").assertIsDisplayed()
}
@Test
- fun importFab_showsSharedContactDialog_whenProvided() {
+ fun importFab_showsSharedContactDialog_whenProvided() = runComposeUiTest {
val contact = SharedContact(user = User(long_name = "Suzume Goddess"), node_num = 1)
- composeTestRule.setContent {
+ setContent {
MeshtasticImportFAB(
onImport = {},
sharedContact = contact,
@@ -113,6 +111,6 @@ class ImportFabUiTest {
}
// Check if goddess is here
- composeTestRule.onNodeWithText("Importing Suzume Goddess").assertIsDisplayed()
+ onNodeWithText("Importing Suzume Goddess").assertIsDisplayed()
}
}
diff --git a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt
similarity index 61%
rename from core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt
rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt
index 5632d39c1..7d2e1d1a4 100644
--- a/core/ui/src/androidTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/AlertManagerUiTest.kt
@@ -18,22 +18,21 @@ package org.meshtastic.core.ui.util
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.ui.test.ExperimentalTestApi
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.Rule
-import org.junit.Test
+import androidx.compose.ui.test.runComposeUiTest
+import kotlin.test.Test
+import kotlin.test.assertTrue
+@OptIn(ExperimentalTestApi::class)
class AlertManagerUiTest {
- @get:Rule val composeTestRule = createComposeRule()
-
- private val alertManager = AlertManager()
-
@Test
- fun alertManager_showsAlert_whenRequested() {
- composeTestRule.setContent {
+ fun alertManager_showsAlert_whenRequested() = runComposeUiTest {
+ val alertManager = AlertManager()
+ setContent {
val alertData by alertManager.currentAlert.collectAsState()
alertData?.let { data -> AlertPreviewRenderer(data) }
}
@@ -43,29 +42,24 @@ class AlertManagerUiTest {
alertManager.showAlert(title = title, message = message)
- composeTestRule.onNodeWithText(title).assertIsDisplayed()
- composeTestRule.onNodeWithText(message).assertIsDisplayed()
+ onNodeWithText(title).assertIsDisplayed()
+ onNodeWithText(message).assertIsDisplayed()
}
@Test
- fun alertManager_confirmButton_triggersCallbackAndDismisses() {
+ fun alertManager_confirmButton_triggersCallbackAndDismisses() = runComposeUiTest {
+ val alertManager = AlertManager()
var confirmClicked = false
- composeTestRule.setContent {
+ setContent {
val alertData by alertManager.currentAlert.collectAsState()
alertData?.let { data -> AlertPreviewRenderer(data) }
}
- alertManager.showAlert(title = "Confirm Title", onConfirm = { confirmClicked = true })
-
- // Default confirm text is "Okay" from resources, but AlertPreviewRenderer uses it
- // We'll search for the text "Okay" (assuming it matches the resource value)
- // Since we are in a test, we might need to use a hardcoded string or a resource
- // But for this test, let's just use the confirmText parameter to be sure
alertManager.showAlert(title = "Confirm Title", confirmText = "Yes", onConfirm = { confirmClicked = true })
- composeTestRule.onNodeWithText("Yes").performClick()
+ onNodeWithText("Yes").performClick()
- assert(confirmClicked)
- composeTestRule.onNodeWithText("Confirm Title").assertDoesNotExist()
+ assertTrue(confirmClicked)
+ onNodeWithText("Confirm Title").assertDoesNotExist()
}
}
diff --git a/docs/decisions/architecture-review-2026-03.md b/docs/decisions/architecture-review-2026-03.md
index 68ed44809..4d225d58c 100644
--- a/docs/decisions/architecture-review-2026-03.md
+++ b/docs/decisions/architecture-review-2026-03.md
@@ -161,16 +161,16 @@ Android uses `@Module`-annotated classes (`CoreDataModule`, `CoreBleAndroidModul
### D1. Zero `commonTest` in feature modules *(resolved 2026-03-12)*
-| Module | `commonTest` | `test`/`androidUnitTest` | `androidTest` |
-|---|---:|---:|---:|
-| `feature:settings` | 22 | 20 | 15 |
-| `feature:node` | 24 | 9 | 0 |
-| `feature:messaging` | 18 | 5 | 3 |
-| `feature:connections` | 27 | 0 | 0 |
-| `feature:firmware` | 15 | 25 | 0 |
-| `feature:wifi-provision` | 62 | 0 | 0 |
+| Module | `commonTest` | `test`/`androidUnitTest` |
+|---|---:|---:|
+| `feature:settings` | 33 | 20 |
+| `feature:node` | 24 | 9 |
+| `feature:messaging` | 21 | 5 |
+| `feature:connections` | 27 | 0 |
+| `feature:firmware` | 15 | 25 |
+| `feature:wifi-provision` | 62 | 0 |
-**Outcome:** All 8 feature modules now have `commonTest` coverage (193 shared tests). Combined with 70 platform unit tests and 18 instrumented tests, feature modules have 281 tests total.
+**Outcome:** All 8 feature modules now have `commonTest` coverage (211 shared tests). Combined with 70 platform unit tests, feature modules have 281 tests total. All Compose UI tests have been migrated from `androidTest` to `commonTest` using CMP `runComposeUiTest`; instrumented test infrastructure has been removed from CI.
### D2. No shared test fixtures *(resolved 2026-03-12)*
diff --git a/feature/firmware/build.gradle.kts b/feature/firmware/build.gradle.kts
index c654e6e6f..a1b35c797 100644
--- a/feature/firmware/build.gradle.kts
+++ b/feature/firmware/build.gradle.kts
@@ -59,13 +59,5 @@ kotlin {
androidMain.dependencies { implementation(libs.markdown.renderer.android) }
commonTest.dependencies { implementation(projects.core.testing) }
-
- val androidHostTest by getting {
- dependencies {
- implementation(libs.junit)
- implementation(libs.kotlinx.coroutines.test)
- implementation(libs.androidx.test.ext.junit)
- }
- }
}
}
diff --git a/feature/intro/build.gradle.kts b/feature/intro/build.gradle.kts
index 1dc180a42..5429361f5 100644
--- a/feature/intro/build.gradle.kts
+++ b/feature/intro/build.gradle.kts
@@ -38,12 +38,5 @@ kotlin {
implementation(libs.jetbrains.navigation3.ui)
}
-
- val androidHostTest by getting {
- dependencies {
- implementation(libs.junit)
- implementation(libs.kotlinx.coroutines.test)
- }
- }
}
}
diff --git a/feature/map/build.gradle.kts b/feature/map/build.gradle.kts
index ebd5ec2c9..db52c350a 100644
--- a/feature/map/build.gradle.kts
+++ b/feature/map/build.gradle.kts
@@ -43,12 +43,5 @@ kotlin {
implementation(projects.core.ui)
implementation(projects.core.di)
}
-
- val androidHostTest by getting {
- dependencies {
- implementation(libs.junit)
- implementation(libs.kotlinx.coroutines.test)
- }
- }
}
}
diff --git a/feature/messaging/build.gradle.kts b/feature/messaging/build.gradle.kts
index e06b417b7..80eed61c5 100644
--- a/feature/messaging/build.gradle.kts
+++ b/feature/messaging/build.gradle.kts
@@ -24,7 +24,6 @@ kotlin {
android {
namespace = "org.meshtastic.feature.messaging"
androidResources.enable = false
- withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
@@ -56,6 +55,8 @@ kotlin {
androidMain.dependencies { implementation(libs.androidx.work.runtime.ktx) }
- val androidHostTest by getting { dependencies { implementation(libs.androidx.work.testing) } }
+ commonTest.dependencies { implementation(libs.compose.multiplatform.ui.test) }
+
+ jvmTest.dependencies { implementation(compose.desktop.currentOs) }
}
}
diff --git a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
similarity index 81%
rename from feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
rename to feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
index 30f65afff..68f7817aa 100644
--- a/feature/messaging/src/androidTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
+++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt
@@ -16,25 +16,21 @@
*/
package org.meshtastic.feature.messaging.component
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
-import androidx.compose.ui.tooling.preview.NodePreviewParameterProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
+import androidx.compose.ui.test.runComposeUiTest
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.model.Message
import org.meshtastic.core.model.MessageStatus
+import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
+import kotlin.test.Test
-@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalTestApi::class)
class MessageItemTest {
- @get:Rule val composeTestRule = createComposeRule()
-
@Test
- fun mqttIconIsDisplayedWhenViaMqttIsTrue() {
+ fun mqttIconIsDisplayedWhenViaMqttIsTrue() = runComposeUiTest {
val testNode = NodePreviewParameterProvider().minnieMouse
val messageWithMqtt =
Message(
@@ -56,7 +52,7 @@ class MessageItemTest {
viaMqtt = true,
)
- composeTestRule.setContent {
+ setContent {
MessageItem(
message = messageWithMqtt,
node = testNode,
@@ -69,11 +65,11 @@ class MessageItemTest {
}
// Check that the MQTT icon is displayed
- composeTestRule.onNodeWithContentDescription("via MQTT").assertIsDisplayed()
+ onNodeWithContentDescription("via MQTT").assertIsDisplayed()
}
@Test
- fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() {
+ fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() = runComposeUiTest {
val testNode = NodePreviewParameterProvider().minnieMouse
val messageWithoutMqtt =
Message(
@@ -95,7 +91,7 @@ class MessageItemTest {
viaMqtt = false,
)
- composeTestRule.setContent {
+ setContent {
MessageItem(
message = messageWithoutMqtt,
node = testNode,
@@ -108,11 +104,11 @@ class MessageItemTest {
}
// Check that the MQTT icon is not displayed
- composeTestRule.onNodeWithContentDescription("via MQTT").assertDoesNotExist()
+ onNodeWithContentDescription("via MQTT").assertDoesNotExist()
}
@Test
- fun messageItem_hasCorrectSemanticContentDescription() {
+ fun messageItem_hasCorrectSemanticContentDescription() = runComposeUiTest {
val testNode = NodePreviewParameterProvider().minnieMouse
val message =
Message(
@@ -134,7 +130,7 @@ class MessageItemTest {
viaMqtt = false,
)
- composeTestRule.setContent {
+ setContent {
MessageItem(
message = message,
node = testNode,
@@ -147,8 +143,6 @@ class MessageItemTest {
}
// Verify that the node containing the message text exists and matches the text
- composeTestRule
- .onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World")
- .assertIsDisplayed()
+ onNodeWithContentDescription("Message from ${testNode.user.long_name}: Hello World").assertIsDisplayed()
}
}
diff --git a/feature/node/build.gradle.kts b/feature/node/build.gradle.kts
index 6195fb13b..0d89b55f6 100644
--- a/feature/node/build.gradle.kts
+++ b/feature/node/build.gradle.kts
@@ -62,14 +62,5 @@ kotlin {
}
androidMain.dependencies { implementation(libs.markdown.renderer.android) }
-
- val androidHostTest by getting {
- dependencies {
- implementation(libs.junit)
- implementation(libs.kotlinx.coroutines.test)
- implementation(libs.androidx.compose.ui.test.junit4)
- implementation(libs.androidx.test.ext.junit)
- }
- }
}
}
diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts
index 4b868fbc4..2793f3625 100644
--- a/feature/settings/build.gradle.kts
+++ b/feature/settings/build.gradle.kts
@@ -26,7 +26,6 @@ kotlin {
android {
namespace = "org.meshtastic.feature.settings"
androidResources.enable = false
- withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
@@ -57,17 +56,11 @@ kotlin {
implementation(libs.androidx.appcompat)
}
- commonTest.dependencies { implementation(project(":core:datastore")) }
-
- val androidHostTest by getting {
- dependencies {
- implementation(project(":core:datastore"))
- implementation(libs.junit)
- implementation(libs.kotlinx.coroutines.test)
- implementation(libs.androidx.compose.ui.test.junit4)
- implementation(libs.androidx.compose.ui.test.manifest)
- implementation(libs.androidx.test.ext.junit)
- }
+ commonTest.dependencies {
+ implementation(project(":core:datastore"))
+ implementation(libs.compose.multiplatform.ui.test)
}
+
+ jvmTest.dependencies { implementation(compose.desktop.currentOs) }
}
}
diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt
deleted file mode 100644
index 9eb31a6e7..000000000
--- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt
+++ /dev/null
@@ -1,98 +0,0 @@
-/*
- * Copyright (c) 2025-2026 Meshtastic LLC
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package org.meshtastic.feature.settings.radio.component
-
-import androidx.compose.foundation.layout.Column
-import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.assertIsNotDisplayed
-import androidx.compose.ui.test.junit4.v2.createComposeRule
-import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.test.platform.app.InstrumentationRegistry
-import org.junit.Assert
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.getString
-import org.meshtastic.core.resources.i_agree
-import org.meshtastic.core.resources.map_reporting
-import org.meshtastic.core.resources.map_reporting_summary
-
-@RunWith(AndroidJUnit4::class)
-class MapReportingPreferenceTest {
-
- @get:Rule val composeTestRule = createComposeRule()
-
- private fun getString(id: Int): String = InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
-
- var mapReportingEnabled = false
- var shouldReportLocation = false
- var positionPrecision = 5
- var positionReportingInterval = 60
-
- var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled }
- var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled }
- var positionPrecisionChanged = { precision: Int -> positionPrecision = precision }
- var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval }
-
- private fun testMapReportingPreference() = composeTestRule.setContent {
- Column {
- MapReportingPreference(
- mapReportingEnabled = mapReportingEnabled,
- shouldReportLocation = shouldReportLocation,
- positionPrecision = positionPrecision,
- onMapReportingEnabledChanged = mapReportingEnabledChanged,
- onShouldReportLocationChanged = shouldReportLocationChanged,
- onPositionPrecisionChanged = positionPrecisionChanged,
- publishIntervalSecs = positionReportingInterval,
- onPublishIntervalSecsChanged = positionReportingIntervalChanged,
- enabled = true,
- )
- }
- }
-
- @Test
- fun testMapReportingPreference_showsText() {
- composeTestRule.apply {
- testMapReportingPreference()
- // Verify that the dialog title is displayed
- onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed()
- onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed()
- }
- }
-
- @Test
- fun testMapReportingPreference_toggleMapReporting() {
- composeTestRule.apply {
- testMapReportingPreference()
- onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed()
- onNodeWithText(getString(Res.string.map_reporting)).performClick()
- Assert.assertFalse(mapReportingEnabled)
- Assert.assertFalse(shouldReportLocation)
- onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed()
- onNodeWithText(getString(Res.string.i_agree)).performClick()
- Assert.assertTrue(shouldReportLocation)
- Assert.assertTrue(mapReportingEnabled)
- onNodeWithText(getString(Res.string.map_reporting)).performClick()
- onNodeWithText(getString(Res.string.i_agree)).assertIsNotDisplayed()
- Assert.assertTrue(shouldReportLocation)
- Assert.assertFalse(mapReportingEnabled)
- }
- }
-}
diff --git a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt
similarity index 71%
rename from feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt
rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt
index b768528e9..f68a79f23 100644
--- a/feature/settings/src/androidHostTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/debugging/DebugSearchTest.kt
@@ -23,17 +23,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
-import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.debug_active_filters
import org.meshtastic.core.resources.debug_default_search
@@ -42,18 +39,15 @@ import org.meshtastic.core.resources.getString
import org.meshtastic.feature.settings.debugging.DebugViewModel.UiMeshLog
import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchMatch
import org.meshtastic.feature.settings.debugging.LogSearchManager.SearchState
-import org.robolectric.annotation.Config
+import kotlin.test.Test
-@RunWith(AndroidJUnit4::class)
-@Config(sdk = [34])
+@OptIn(ExperimentalTestApi::class)
class DebugSearchTest {
- @get:Rule val composeTestRule = createComposeRule()
-
@Test
- fun debugSearchBar_showsPlaceholder() {
+ fun debugSearchBar_showsPlaceholder() = runComposeUiTest {
val placeholder = getString(Res.string.debug_default_search)
- composeTestRule.setContent {
+ setContent {
DebugSearchBar(
searchState = SearchState(),
onSearchTextChange = {},
@@ -62,13 +56,13 @@ class DebugSearchTest {
onClearSearch = {},
)
}
- composeTestRule.onNodeWithText(placeholder).assertIsDisplayed()
+ onNodeWithText(placeholder).assertIsDisplayed()
}
@Test
- fun debugSearchBar_showsClearButtonWhenTextEntered() {
+ fun debugSearchBar_showsClearButtonWhenTextEntered() = runComposeUiTest {
val placeholder = getString(Res.string.debug_default_search)
- composeTestRule.setContent {
+ setContent {
var searchText by remember { mutableStateOf("test") }
DebugSearchBar(
searchState = SearchState(searchText = searchText),
@@ -78,17 +72,17 @@ class DebugSearchTest {
onClearSearch = { searchText = "" },
)
}
- composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick()
- composeTestRule.onNodeWithText(placeholder).assertIsDisplayed()
+ onNodeWithContentDescription("Clear search").assertIsDisplayed().performClick()
+ onNodeWithText(placeholder).assertIsDisplayed()
}
@Test
- fun debugSearchBar_searchFor_showsArrowsClearAndValues() {
+ fun debugSearchBar_searchFor_showsArrowsClearAndValues() = runComposeUiTest {
val searchText = "test"
val matchCount = 3
val currentMatchIndex = 1
- composeTestRule.setContent {
+ setContent {
DebugSearchBar(
searchState =
SearchState(
@@ -104,18 +98,18 @@ class DebugSearchTest {
)
}
// Check the match count display (e.g., '2/3')
- composeTestRule.onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed()
+ onNodeWithText("${currentMatchIndex + 1}/$matchCount").assertIsDisplayed()
// Check the navigation arrows
- composeTestRule.onNodeWithContentDescription("Previous match").assertIsDisplayed()
- composeTestRule.onNodeWithContentDescription("Next match").assertIsDisplayed()
+ onNodeWithContentDescription("Previous match").assertIsDisplayed()
+ onNodeWithContentDescription("Next match").assertIsDisplayed()
// Check the clear button
- composeTestRule.onNodeWithContentDescription("Clear search").assertIsDisplayed()
+ onNodeWithContentDescription("Clear search").assertIsDisplayed()
}
@Test
- fun debugFilterBar_showsFilterButtonAndMenu() {
+ fun debugFilterBar_showsFilterButtonAndMenu() = runComposeUiTest {
val filterLabel = getString(Res.string.debug_filters)
- composeTestRule.setContent {
+ setContent {
var filterTexts by remember { mutableStateOf(listOf()) }
var customFilterText by remember { mutableStateOf("") }
val presetFilters = listOf("Error", "Warning", "Info")
@@ -138,13 +132,13 @@ class DebugSearchTest {
)
}
// The filter button should be visible
- composeTestRule.onNodeWithText(filterLabel).assertIsDisplayed()
+ onNodeWithText(filterLabel).assertIsDisplayed()
}
@Test
- fun debugFilterBar_addCustomFilter_displaysActiveFilter() {
+ fun debugFilterBar_addCustomFilter_displaysActiveFilter() = runComposeUiTest {
val activeFiltersLabel = getString(Res.string.debug_active_filters)
- composeTestRule.setContent {
+ setContent {
var filterTexts by remember { mutableStateOf(listOf()) }
var customFilterText by remember { mutableStateOf("") }
Column(modifier = Modifier.padding(16.dp)) {
@@ -162,18 +156,16 @@ class DebugSearchTest {
)
}
}
- with(composeTestRule) {
- onNodeWithText("Add custom filter").performTextInput("MyFilter")
- onNodeWithContentDescription("Add filter").performClick()
- onNodeWithText(activeFiltersLabel).assertIsDisplayed()
- onNodeWithText("MyFilter").assertIsDisplayed()
- }
+ onNodeWithText("Add custom filter").performTextInput("MyFilter")
+ onNodeWithContentDescription("Add filter").performClick()
+ onNodeWithText(activeFiltersLabel).assertIsDisplayed()
+ onNodeWithText("MyFilter").assertIsDisplayed()
}
@Test
- fun debugActiveFilters_clearAllFilters_removesFilters() {
+ fun debugActiveFilters_clearAllFilters_removesFilters() = runComposeUiTest {
val activeFiltersLabel = getString(Res.string.debug_active_filters)
- composeTestRule.setContent {
+ setContent {
var filterTexts by remember { mutableStateOf(listOf("A", "B")) }
DebugActiveFilters(
filterTexts = filterTexts,
@@ -183,13 +175,13 @@ class DebugSearchTest {
)
}
// The active filters label and chips should be visible
- composeTestRule.onNodeWithText(activeFiltersLabel).assertIsDisplayed()
- composeTestRule.onNodeWithText("A").assertIsDisplayed()
- composeTestRule.onNodeWithText("B").assertIsDisplayed()
+ onNodeWithText(activeFiltersLabel).assertIsDisplayed()
+ onNodeWithText("A").assertIsDisplayed()
+ onNodeWithText("B").assertIsDisplayed()
// Click the clear all filters button
- composeTestRule.onNodeWithContentDescription("Clear all filters").performClick()
+ onNodeWithContentDescription("Clear all filters").performClick()
// The filter chips should no longer be visible
- composeTestRule.onNodeWithText("A").assertDoesNotExist()
- composeTestRule.onNodeWithText("B").assertDoesNotExist()
+ onNodeWithText("A").assertDoesNotExist()
+ onNodeWithText("B").assertDoesNotExist()
}
}
diff --git a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt
similarity index 54%
rename from feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt
rename to feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt
index 1f390e44e..61d3b1219 100644
--- a/feature/settings/src/androidTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/EditDeviceProfileDialogTest.kt
@@ -16,27 +16,24 @@
*/
package org.meshtastic.feature.settings.radio.component
+import androidx.compose.ui.test.ExperimentalTestApi
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 androidx.test.ext.junit.runners.AndroidJUnit4
-import org.junit.Assert
-import org.junit.Rule
-import org.junit.Test
-import org.junit.runner.RunWith
+import androidx.compose.ui.test.runComposeUiTest
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.save
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.Position
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
-@RunWith(AndroidJUnit4::class)
+@OptIn(ExperimentalTestApi::class)
class EditDeviceProfileDialogTest {
- @get:Rule val composeTestRule = createComposeRule()
-
private val title = "Export configuration"
private val deviceProfile =
DeviceProfile(
@@ -46,61 +43,61 @@ class EditDeviceProfileDialogTest {
fixed_position = Position(latitude_i = 327766650, longitude_i = -967969890, altitude = 138),
)
- private fun testEditDeviceProfileDialog(onDismiss: () -> Unit = {}, onConfirm: (DeviceProfile) -> Unit = {}) =
- composeTestRule.setContent {
+ @Test
+ fun testEditDeviceProfileDialog_showsDialogTitle() = runComposeUiTest {
+ setContent {
+ EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {})
+ }
+
+ // Verify that the dialog title is displayed
+ onNodeWithText(title).assertIsDisplayed()
+ }
+
+ @Test
+ fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() = runComposeUiTest {
+ setContent {
+ EditDeviceProfileDialog(title = title, deviceProfile = deviceProfile, onConfirm = {}, onDismiss = {})
+ }
+
+ // Verify the "Cancel" and "Save" buttons are displayed
+ onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.save)).assertIsDisplayed()
+ }
+
+ @Test
+ fun testEditDeviceProfileDialog_clickCancelButton() = runComposeUiTest {
+ var onDismissClicked = false
+ setContent {
EditDeviceProfileDialog(
title = title,
deviceProfile = deviceProfile,
- onConfirm = onConfirm,
- onDismiss = onDismiss,
+ onConfirm = {},
+ onDismiss = { onDismissClicked = true },
)
}
- @Test
- fun testEditDeviceProfileDialog_showsDialogTitle() {
- composeTestRule.apply {
- testEditDeviceProfileDialog()
-
- // Verify that the dialog title is displayed
- onNodeWithText(title).assertIsDisplayed()
- }
- }
-
- @Test
- fun testEditDeviceProfileDialog_showsCancelAndSaveButtons() {
- composeTestRule.apply {
- testEditDeviceProfileDialog()
-
- // Verify the "Cancel" and "Save" buttons are displayed
- onNodeWithText(getString(Res.string.cancel)).assertIsDisplayed()
- onNodeWithText(getString(Res.string.save)).assertIsDisplayed()
- }
- }
-
- @Test
- fun testEditDeviceProfileDialog_clickCancelButton() {
- var onDismissClicked = false
- composeTestRule.apply {
- testEditDeviceProfileDialog(onDismiss = { onDismissClicked = true })
-
- // Click the "Cancel" button
- onNodeWithText(getString(Res.string.cancel)).performClick()
- }
+ // Click the "Cancel" button
+ onNodeWithText(getString(Res.string.cancel)).performClick()
// Verify onDismiss is called
- Assert.assertTrue(onDismissClicked)
+ assertTrue(onDismissClicked)
}
@Test
- fun testEditDeviceProfileDialog_addChannels() {
+ fun testEditDeviceProfileDialog_addChannels() = runComposeUiTest {
var actualDeviceProfile: DeviceProfile? = null
- composeTestRule.apply {
- testEditDeviceProfileDialog(onConfirm = { actualDeviceProfile = it })
-
- onNodeWithText(getString(Res.string.save)).performClick()
+ setContent {
+ EditDeviceProfileDialog(
+ title = title,
+ deviceProfile = deviceProfile,
+ onConfirm = { actualDeviceProfile = it },
+ onDismiss = {},
+ )
}
+ onNodeWithText(getString(Res.string.save)).performClick()
+
// Verify onConfirm is called with the correct DeviceProfile
- Assert.assertEquals(deviceProfile, actualDeviceProfile)
+ assertEquals(deviceProfile, actualDeviceProfile)
}
}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt
new file mode 100644
index 000000000..850cc93e7
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/MapReportingPreferenceTest.kt
@@ -0,0 +1,99 @@
+/*
+ * Copyright (c) 2025-2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.meshtastic.feature.settings.radio.component
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.runComposeUiTest
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.i_agree
+import org.meshtastic.core.resources.map_reporting
+import org.meshtastic.core.resources.map_reporting_summary
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalTestApi::class)
+class MapReportingPreferenceTest {
+
+ var mapReportingEnabled = false
+ var shouldReportLocation = false
+ var positionPrecision = 5
+ var positionReportingInterval = 60
+
+ var mapReportingEnabledChanged = { enabled: Boolean -> mapReportingEnabled = enabled }
+ var shouldReportLocationChanged = { enabled: Boolean -> shouldReportLocation = enabled }
+ var positionPrecisionChanged = { precision: Int -> positionPrecision = precision }
+ var positionReportingIntervalChanged = { interval: Int -> positionReportingInterval = interval }
+
+ @Test
+ fun testMapReportingPreference_showsText() = runComposeUiTest {
+ setContent {
+ Column {
+ MapReportingPreference(
+ mapReportingEnabled = mapReportingEnabled,
+ shouldReportLocation = shouldReportLocation,
+ positionPrecision = positionPrecision,
+ onMapReportingEnabledChanged = mapReportingEnabledChanged,
+ onShouldReportLocationChanged = shouldReportLocationChanged,
+ onPositionPrecisionChanged = positionPrecisionChanged,
+ publishIntervalSecs = positionReportingInterval,
+ onPublishIntervalSecsChanged = positionReportingIntervalChanged,
+ enabled = true,
+ )
+ }
+ }
+ // Verify that the dialog title is displayed
+ onNodeWithText(getString(Res.string.map_reporting)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.map_reporting_summary)).assertIsDisplayed()
+ }
+
+ @Test
+ fun testMapReportingPreference_toggleMapReporting() = runComposeUiTest {
+ setContent {
+ Column {
+ MapReportingPreference(
+ mapReportingEnabled = mapReportingEnabled,
+ shouldReportLocation = shouldReportLocation,
+ positionPrecision = positionPrecision,
+ onMapReportingEnabledChanged = mapReportingEnabledChanged,
+ onShouldReportLocationChanged = shouldReportLocationChanged,
+ onPositionPrecisionChanged = positionPrecisionChanged,
+ publishIntervalSecs = positionReportingInterval,
+ onPublishIntervalSecsChanged = positionReportingIntervalChanged,
+ enabled = true,
+ )
+ }
+ }
+ onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist()
+ onNodeWithText(getString(Res.string.map_reporting)).performClick()
+ assertFalse(mapReportingEnabled)
+ assertFalse(shouldReportLocation)
+ onNodeWithText(getString(Res.string.i_agree)).assertIsDisplayed()
+ onNodeWithText(getString(Res.string.i_agree)).performClick()
+ assertTrue(shouldReportLocation)
+ assertTrue(mapReportingEnabled)
+ onNodeWithText(getString(Res.string.map_reporting)).performClick()
+ onNodeWithText(getString(Res.string.i_agree)).assertDoesNotExist()
+ assertTrue(shouldReportLocation)
+ assertFalse(mapReportingEnabled)
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e1c5630ab..404b9f80e 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -129,6 +129,7 @@ compose-multiplatform-runtime = { module = "org.jetbrains.compose.runtime:runtim
compose-multiplatform-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
compose-multiplatform-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
compose-multiplatform-ui-tooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "compose-multiplatform" }
+compose-multiplatform-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "compose-multiplatform" }
compose-multiplatform-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
compose-multiplatform-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "compose-multiplatform" }
compose-multiplatform-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "compose-multiplatform-material3" }