diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 18:18:17 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-03-25 18:18:17 +0000 |
| commit | ea5cdac728263fdc48b480460f3362a7f5fe221d (patch) | |
| tree | 880fb6608535f5a95cad8fbf82c785dd2cb0e8b3 | |
| parent | ca57e40adc0b89e7dc5409475f7510c0c188d715 (diff) | |
test(ci): share APKs between jobs and expand smoke tests
CI — build job now uploads both APKs as the 'test-apks' artifact.
smoke-test job downloads them and passes -x assembleDebug
-x assembleDebugAndroidTest to skip recompilation (~4 min saved).
Test results uploaded as 'smoke-test-results' artifact on every run.
Smoke tests expanded from 1 → 11 tests covering:
- MainActivity launches without crash
- All 4 bottom-nav tabs are displayed
- Safety tab: Safety Dashboard, ACTIVATE MOB, ANCHOR WATCH visible
- Log tab: voice-log mic FAB visible
- Instruments tab: bottom sheet displayed
- Map tab: returns from overlay, mapView visible
- MOB FAB: always visible, visible on Safety tab
- Record Track FAB: displayed, toggles to Stop Recording, toggles back
MainActivity: moved isRecording observer to initializeUI() so the
FAB content description updates without requiring GPS permission
(needed for emulator tests that run without location permission).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
3 files changed, 137 insertions, 17 deletions
diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 12b0bc9..150da6f 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -27,12 +27,20 @@ jobs: run: ./gradlew assembleDebug assembleDebugAndroidTest working-directory: android-app - - name: Upload artifact + - name: Upload app APK (Firebase / manual download) uses: actions/upload-artifact@v4 with: name: app-debug path: android-app/app/build/outputs/apk/debug/app-debug.apk + - name: Upload test APKs (shared with smoke-test job) + uses: actions/upload-artifact@v4 + with: + name: test-apks + path: | + android-app/app/build/outputs/apk/debug/app-debug.apk + android-app/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk + - name: upload artifact to Firebase App Distribution if: github.ref == 'refs/heads/main' uses: wzieba/Firebase-Distribution-Github-Action@v1 @@ -66,7 +74,7 @@ jobs: smoke-test: runs-on: ubuntu-latest - # Run after build succeeds so we don't spin up an emulator for a broken build + # Run after build succeeds — no point spinning up an emulator for a broken build needs: build steps: @@ -82,21 +90,36 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x android-app/gradlew + # Restore pre-built APKs so the emulator job skips the compile step + - name: Download test APKs + uses: actions/download-artifact@v4 + with: + name: test-apks + path: . # preserves android-app/app/build/outputs/… directory structure + - name: Enable KVM (faster emulator) 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 + # -x assembleDebug -x assembleDebugAndroidTest: skip recompile, use downloaded APKs - name: Run smoke tests on emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: 30 arch: x86_64 profile: pixel_3a - script: ./gradlew connectedDebugAndroidTest + script: ./gradlew connectedDebugAndroidTest -x assembleDebug -x assembleDebugAndroidTest working-directory: android-app + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: smoke-test-results + path: android-app/app/build/outputs/androidTest-results/ + - name: Notify claudomator if: always() env: diff --git a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt index 0824abe..a13ef7f 100644 --- a/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt +++ b/android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt @@ -1,18 +1,24 @@ package org.terst.nav import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Before import org.junit.Test import org.junit.runner.RunWith /** - * Smoke test: verifies MainActivity launches without crashing. + * Smoke tests: verify the main UI surfaces launch and respond correctly. + * These run on an emulator without GPS permission, so no LocationService. * - * Run on an emulator/device via: - * ./gradlew connectedDebugAndroidTest - * - * In CI, requires an emulator step before the Gradle task. + * Run locally: ./gradlew connectedDebugAndroidTest + * In CI: smoke-test job via android-emulator-runner */ @RunWith(AndroidJUnit4::class) class MainActivitySmokeTest { @@ -22,14 +28,104 @@ class MainActivitySmokeTest { NavApplication.isTesting = true } + // ── Launch ───────────────────────────────────────────────────────────── + @Test fun mainActivity_launches_withoutCrash() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> - // If we reach this line the activity started without throwing. - // onActivity lets us assert it is in a resumed state. scenario.onActivity { activity -> assert(!activity.isFinishing) { "MainActivity finished immediately after launch" } } } } + + // ── Bottom nav ───────────────────────────────────────────────────────── + + @Test + fun bottomNav_allFourTabs_areDisplayed() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withText("Map")).check(matches(isDisplayed())) + onView(withText("Instruments")).check(matches(isDisplayed())) + onView(withText("Log")).check(matches(isDisplayed())) + onView(withText("Safety")).check(matches(isDisplayed())) + } + } + + @Test + fun bottomNav_safetyTab_showsSafetyDashboard() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withText("Safety")).perform(click()) + onView(withText("Safety Dashboard")).check(matches(isDisplayed())) + onView(withText("ACTIVATE MOB")).check(matches(isDisplayed())) + onView(withText("ANCHOR WATCH")).check(matches(isDisplayed())) + } + } + + @Test + fun bottomNav_logTab_showsVoiceLogUi() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withText("Log")).perform(click()) + onView(withContentDescription("Start voice recognition")).check(matches(isDisplayed())) + } + } + + @Test + fun bottomNav_instrumentsTab_isSelectable() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withText("Instruments")).perform(click()) + onView(withId(R.id.instrument_bottom_sheet)).check(matches(isDisplayed())) + } + } + + @Test + fun bottomNav_mapTab_returnsFromOverlay() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withText("Safety")).perform(click()) + onView(withText("Map")).perform(click()) + onView(withId(R.id.mapView)).check(matches(isDisplayed())) + } + } + + // ── Persistent FABs ──────────────────────────────────────────────────── + + @Test + fun fabMob_isAlwaysVisible() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) + } + } + + @Test + fun fabMob_remainsVisibleOnSafetyTab() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withText("Safety")).perform(click()) + onView(withContentDescription("Man Overboard")).check(matches(isDisplayed())) + } + } + + // ── Track recording ──────────────────────────────────────────────────── + + @Test + fun fabRecordTrack_isDisplayedWithRecordDescription() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withContentDescription("Record Track")).check(matches(isDisplayed())) + } + } + + @Test + fun fabRecordTrack_togglesToStopRecording_onFirstClick() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withContentDescription("Record Track")).perform(click()) + onView(withContentDescription("Stop Recording")).check(matches(isDisplayed())) + } + } + + @Test + fun fabRecordTrack_togglesBackToRecord_onSecondClick() { + ActivityScenario.launch(MainActivity::class.java).use { + onView(withContentDescription("Record Track")).perform(click()) + onView(withContentDescription("Stop Recording")).perform(click()) + onView(withContentDescription("Record Track")).check(matches(isDisplayed())) + } + } } diff --git a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt index ecaddc0..f887a43 100644 --- a/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt +++ b/android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt @@ -88,6 +88,14 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { fabRecordTrack.setOnClickListener { if (viewModel.isRecording.value) viewModel.stopTrack() else viewModel.startTrack() } + // Observe immediately — pure UI state, not gated on GPS permission + lifecycleScope.launch { + viewModel.isRecording.collect { recording -> + val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record + fabRecordTrack.setImageResource(icon) + fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track" + } + } } private fun setupBottomSheet() { @@ -262,13 +270,6 @@ class MainActivity : AppCompatActivity(), SafetyFragment.SafetyListener { mapHandler?.updateTrackLayer(style, points) } } - lifecycleScope.launch { - viewModel.isRecording.collect { recording -> - val icon = if (recording) R.drawable.ic_close else R.drawable.ic_track_record - fabRecordTrack.setImageResource(icon) - fabRecordTrack.contentDescription = if (recording) "Stop Recording" else "Record Track" - } - } } private fun startInstrumentSimulation(polarTable: PolarTable) { |
