summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-25 18:18:17 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-25 18:18:17 +0000
commitea5cdac728263fdc48b480460f3362a7f5fe221d (patch)
tree880fb6608535f5a95cad8fbf82c785dd2cb0e8b3
parentca57e40adc0b89e7dc5409475f7510c0c188d715 (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>
-rw-r--r--.github/workflows/android.yml29
-rw-r--r--android-app/app/src/androidTest/kotlin/org/terst/nav/MainActivitySmokeTest.kt110
-rw-r--r--android-app/app/src/main/kotlin/org/terst/nav/MainActivity.kt15
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) {