From 8fcd56cab17a14b4de00ffce8353967eb9d42eb9 Mon Sep 17 00:00:00 2001 From: scrimshawlife-ctrl Date: Wed, 7 Oct 2026 13:51:25 -0700 Subject: [PATCH 1/3] test: emulator CI for debug demo and LOCAL launchers Adds a KVM Linux emulator job with disk cleanup and large userdata partition, plus androidTestDebug Espresso/Compose journeys for DemoRoot and LocalRoot (10.0.2.2 Worker via npm run dev:demo). Keeps the existing contract job unchanged. Code drafted by DeepSeek 4.1 Flash via OpenRouter. --- .github/workflows/ci.yml | 122 ++++++++++++++++++ .../suas/demo/DemoLauncherSignInTest.kt | 58 +++++++++ .../com/example/suas/demo/LauncherJourney.kt | 92 +++++++++++++ .../suas/demo/LocalLauncherSignInTest.kt | 50 +++++++ 4 files changed, 322 insertions(+) create mode 100644 app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt create mode 100644 app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt create mode 100644 app/src/androidTestDebug/java/com/example/suas/demo/LocalLauncherSignInTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce60be9..1d6afc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,11 @@ # Android hygiene CI. SPEC-018 remains KEEP_BLOCKED. # Emulator/instrumented acceptance is not claimed here. # DEVICE_ACCEPTANCE_REQUIRED remains for on-device checks. +# +# Added on demo-data-mode: the `demo-launchers` job is the only emulator job. It launches each +# debug-only demo launcher (in-memory DemoSuasApi, and the real client against a LOCAL Worker on +# http://10.0.2.2:3000) and walks sign-in plus the seeded service screens. It is not on-device +# acceptance, it does not replace DEVICE_ACCEPTANCE_REQUIRED, and it does not change `contract`. name: ci @@ -42,3 +47,120 @@ jobs: name: android-release-apk path: app/build/outputs/apk/release/*.apk if-no-files-found: ignore + + demo-launchers: + name: demo launchers (emulator) + # Pull requests only. Building an AVD and running two instrumented journeys takes tens of + # minutes and is not meant for every push to main. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + # GitHub-hosted Linux runners expose /dev/kvm; the udev rule is the documented way to + # make it usable by the runner user. + - name: Enable KVM group perms + 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 + ls -l /dev/kvm + + # The emulator reserves the userdata partition as a real file on disk. This has to run + # before the AVD is created, otherwise avdmanager/the emulator fails with + # "Not enough space to create userdata partition". + - name: Free disk space before creating the AVD + run: | + df -h / + sudo rm -rf \ + /usr/share/dotnet \ + /opt/ghc \ + /opt/hostedtoolcache/CodeQL \ + /usr/local/share/boost \ + /usr/local/lib/android/sdk/ndk \ + /opt/microsoft/msodbcsql17 \ + /opt/microsoft/msodbcsql18 \ + || true + sudo apt-get clean + df -h / + + - uses: actions/checkout@v5 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + # LOCAL demo path: the Worker that `Backend.LOCAL_BASE` (http://10.0.2.2:3000) points at. + - name: Checkout suas Worker (main) + uses: actions/checkout@v5 + with: + repository: scrimshawlife-ctrl/suas + ref: main + path: suas + # suas is public; default GITHUB_TOKEN is enough to checkout it + # If that ever changes, Danny can add a read-only SUAS_CHECKOUT_TOKEN secret. + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install Worker dependencies + working-directory: suas + run: | + if [ -f package-lock.json ]; then npm ci; else npm install; fi + + - name: Start LOCAL demo Worker (npm run dev:demo) + working-directory: suas + run: | + nohup npm run dev:demo > "$GITHUB_WORKSPACE/dev-demo.log" 2>&1 < /dev/null & + echo $! > /tmp/dev-demo.pid + echo "worker pid $(cat /tmp/dev-demo.pid)" + + # 10.0.2.2 inside the emulator is the host loopback, so the Worker may stay bound to + # 127.0.0.1. Only the host port has to be open. + - name: Wait for the Worker on host port 3000 + run: | + for _ in $(seq 1 150); do + if (echo > /dev/tcp/127.0.0.1/3000) >/dev/null 2>&1; then + echo "worker listening on 127.0.0.1:3000; emulator reaches it at 10.0.2.2:3000" + exit 0 + fi + sleep 2 + done + echo "worker did not open port 3000" + tail -n 200 "$GITHUB_WORKSPACE/dev-demo.log" || true + exit 1 + + - name: Instrumented launcher tests on the emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 34 + target: google_apis + arch: x86_64 + profile: pixel_6 + avd-name: suas-demo + force-avd-creation: true + disable-animations: true + emulator-boot-timeout: 900 + disk-size: 4096M + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 4096 -disk-size 4096M + script: | + adb devices + chmod +x ./gradlew + ./gradlew :app:connectedDebugAndroidTest --stacktrace + + - name: Worker log + if: always() + run: tail -n 300 "$GITHUB_WORKSPACE/dev-demo.log" || true + + - name: Upload instrumented test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-instrumented-reports + path: | + app/build/reports/androidTests/** + app/build/outputs/androidTest-results/** + dev-demo.log + if-no-files-found: ignore diff --git a/app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt b/app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt new file mode 100644 index 0000000..bba4669 --- /dev/null +++ b/app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt @@ -0,0 +1,58 @@ +package com.example.suas.demo + +import androidx.compose.ui.test.assertExists +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.suas.DemoRootActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Emulator acceptance for the in-memory demo launcher (DemoRootActivity, src/debug only). + * + * Home -> Sign in -> synthetic 246810 -> every seeded service screen. No server and no network: + * the whole journey runs against DemoSuasApi and contract/demo-fixtures.json. + */ +@RunWith(AndroidJUnit4::class) +class DemoLauncherSignInTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun inMemoryDemoLauncherSignsInAndWalksEachSeededScreen() { + composeRule.onNodeWithText("Demo mode: synthetic data, no server", substring = true) + .assertIsDisplayed() + + composeRule.openSignIn() + composeRule.onNodeWithText("Demo sign-in: $DEMO_EMAIL, code $DEMO_CODE", substring = true) + .assertIsDisplayed() + composeRule.onNodeWithText(DEMO_EMAIL, substring = true).assertIsDisplayed() + + composeRule.signInWithDemoCode() + composeRule.awaitSignedIn() + + // Every seeded service screen opens, shows a submit control, and reports the seeded + // demo request for that category through the demo-only advance control. + for (title in SEEDED_SCREEN_TITLES) { + composeRule.walkSeededScreen(title) { + composeRule.onNodeWithText("Demo only: advance status", substring = true) + .assertExists() + } + } + + // One seeded request moves a status step inside the demo launcher. + composeRule.walkSeededScreen("Transportation") { + composeRule.onNodeWithText("Demo only: advance status", substring = true).performClick() + composeRule.onNodeWithText("Demo only: advance status", substring = true).assertExists() + } + + composeRule.onNodeWithText("Transportation", substring = false).performScrollTo() + .assertIsDisplayed() + } +} diff --git a/app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt b/app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt new file mode 100644 index 0000000..83d7874 --- /dev/null +++ b/app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt @@ -0,0 +1,92 @@ +package com.example.suas.demo + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.onAllNodes +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput + +/** + * Shared steps for the two debug-only demo launcher journeys. + * + * Only text the product shell renders is used, so the steps keep working when copy changes. + * This source set is androidTestDebug: it is compiled for the debug variant only and never + * ships in a release build. + */ + +/** Synthetic veteran seeded in suas contract/demo-fixtures.json. Not a real person. */ +internal const val DEMO_EMAIL = "veteran@example.invalid" + +/** Synthetic one-time code seeded in suas contract/demo-fixtures.json and in the demo Worker. */ +internal const val DEMO_CODE = "246810" + +/** The four seeded service screens on the home surface. */ +internal val SEEDED_SCREEN_TITLES = + listOf("Transportation", "Food", "Temporary Shelter", "Peer Support") + +/** In-memory demo answers without IO, so this is generous. */ +internal const val IN_MEMORY_TIMEOUT_MS = 30_000L + +/** LOCAL answers over the emulator's 10.0.2.2 hop to the host Worker. */ +internal const val LOCAL_TIMEOUT_MS = 90_000L + +/** Home -> Sign in. The home button and the sign-in heading share the same label. */ +internal fun ComposeTestRule.openSignIn() { + waitUntil(timeoutMillis = 15_000) { + onAllNodesWithText("Sign in").fetchSemanticsNodes().isNotEmpty() + } + onAllNodesWithText("Sign in")[0].performClick() + onNodeWithText("Sign in").assertIsDisplayed() +} + +/** + * Sign-in screen -> signed-in home, using the synthetic demo email (already prefilled by both + * launchers) and the synthetic one-time code. + */ +internal fun ComposeTestRule.signInWithDemoCode( + code: String = DEMO_CODE, + timeoutMillis: Long = IN_MEMORY_TIMEOUT_MS, +) { + onNodeWithText("Send sign-in code").performClick() + + // The code field is added once the challenge call returns. The email field is already on + // screen, so the code field is the second editable node. + waitUntil(timeoutMillis = timeoutMillis) { + onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size >= 2 + } + onAllNodes(hasSetTextAction())[1].performTextInput(code) + onNodeWithText("Verify").performClick() +} + +/** Waits for the home surface to report a session, then asserts it. */ +internal fun ComposeTestRule.awaitSignedIn(timeoutMillis: Long = IN_MEMORY_TIMEOUT_MS) { + waitUntil(timeoutMillis = timeoutMillis) { + onAllNodesWithText("Sign out").fetchSemanticsNodes().isNotEmpty() + } + onNodeWithText("Sign out").assertIsDisplayed() +} + +/** + * Opens one seeded service screen from the home surface, asserts the screen and its submit + * control, runs [onScreen] while it is open, then returns home. + */ +internal fun ComposeTestRule.walkSeededScreen(title: String, onScreen: () -> Unit = {}) { + onNodeWithText(title).performScrollTo().performClick() + onNodeWithText(title).assertIsDisplayed() + onNodeWithText("Submit request").assertIsDisplayed() + onScreen() + onNodeWithText("Back").performClick() + onNodeWithText("Sign out").assertIsDisplayed() +} + +/** Ride form labels only. No coordinates, no booking. */ +internal fun ComposeTestRule.fillRideForm(pickup: String, destination: String) { + val fields = onAllNodes(hasSetTextAction()).fetchSemanticsNodes() + check(fields.size >= 2) { "expected pickup and destination fields, found ${fields.size}" } + onAllNodes(hasSetTextAction())[0].performTextInput(pickup) + onAllNodes(hasSetTextAction())[1].performTextInput(destination) +} diff --git a/app/src/androidTestDebug/java/com/example/suas/demo/LocalLauncherSignInTest.kt b/app/src/androidTestDebug/java/com/example/suas/demo/LocalLauncherSignInTest.kt new file mode 100644 index 0000000..633a6df --- /dev/null +++ b/app/src/androidTestDebug/java/com/example/suas/demo/LocalLauncherSignInTest.kt @@ -0,0 +1,50 @@ +package com.example.suas.demo + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.example.suas.LocalRootActivity +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Emulator acceptance for the LOCAL launcher (LocalRootActivity, src/debug only). + * + * Same journey as the in-memory launcher, but the client is the real /api/v0 client pointed at + * the host Worker (npm run dev:demo in scrimshawlife-ctrl/suas) reached from the emulator at + * http://10.0.2.2:3000. + */ +@RunWith(AndroidJUnit4::class) +class LocalLauncherSignInTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun localWorkerLauncherSignsInAndWalksEachSeededScreen() { + composeRule.onNodeWithText("LOCAL Worker at http://10.0.2.2:3000", substring = true) + .assertIsDisplayed() + + composeRule.openSignIn() + composeRule.signInWithDemoCode(timeoutMillis = LOCAL_TIMEOUT_MS) + composeRule.awaitSignedIn(timeoutMillis = LOCAL_TIMEOUT_MS) + + for (title in SEEDED_SCREEN_TITLES) { + composeRule.walkSeededScreen(title) + } + + // One real /api/v0 round trip through the host Worker. Labels only, no coordinates. + composeRule.onNodeWithText("Transportation").performScrollTo().performClick() + composeRule.fillRideForm(pickup = "123 Main St", destination = "VA Clinic") + composeRule.onNodeWithText("Submit request").performClick() + composeRule.waitUntil(timeoutMillis = LOCAL_TIMEOUT_MS) { + composeRule.onAllNodesWithText("Cancel request").fetchSemanticsNodes().isNotEmpty() + } + composeRule.onNodeWithText("Cancel request").assertIsDisplayed() + } +} From 9b4d4d677d9f95490ba5319c98ba251d37ddf6c0 Mon Sep 17 00:00:00 2001 From: scrimshawlife-ctrl Date: Wed, 7 Oct 2026 14:12:33 -0700 Subject: [PATCH 2/3] fix(ci): drop invalid -disk-size emulator flag Emulator 37 rejects -disk-size in emulator-options ("unknown option"), so the AVD never booted. Keep reactivecircus disk-size input and -partition-size for userdata space. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d6afc1..0fa99c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,7 @@ jobs: disable-animations: true emulator-boot-timeout: 900 disk-size: 4096M - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 4096 -disk-size 4096M + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 4096 script: | adb devices chmod +x ./gradlew From b3243dc16cbe15517ec9c735a7071c7e1fe0ab55 Mon Sep 17 00:00:00 2001 From: scrimshawlife-ctrl Date: Wed, 7 Oct 2026 14:32:06 -0700 Subject: [PATCH 3/3] fix(test): resolve Compose UI test imports in androidTestDebug assertExists and onAllNodes need the SemanticsNodeInteraction / SemanticsNodeInteractionCollection imports. One OpenRouter fix round after CI compile failure. --- .../example/suas/demo/DemoLauncherSignInTest.kt | 6 ++---- .../com/example/suas/demo/LauncherJourney.kt | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt b/app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt index bba4669..8d5f60e 100644 --- a/app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt +++ b/app/src/androidTestDebug/java/com/example/suas/demo/DemoLauncherSignInTest.kt @@ -1,6 +1,5 @@ package com.example.suas.demo -import androidx.compose.ui.test.assertExists import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText @@ -41,15 +40,14 @@ class DemoLauncherSignInTest { // demo request for that category through the demo-only advance control. for (title in SEEDED_SCREEN_TITLES) { composeRule.walkSeededScreen(title) { - composeRule.onNodeWithText("Demo only: advance status", substring = true) - .assertExists() + composeRule.awaitTextExists("Demo only: advance status") } } // One seeded request moves a status step inside the demo launcher. composeRule.walkSeededScreen("Transportation") { composeRule.onNodeWithText("Demo only: advance status", substring = true).performClick() - composeRule.onNodeWithText("Demo only: advance status", substring = true).assertExists() + composeRule.awaitTextExists("Demo only: advance status") } composeRule.onNodeWithText("Transportation", substring = false).performScrollTo() diff --git a/app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt b/app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt index 83d7874..d320a25 100644 --- a/app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt +++ b/app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt @@ -70,6 +70,23 @@ internal fun ComposeTestRule.awaitSignedIn(timeoutMillis: Long = IN_MEMORY_TIMEO onNodeWithText("Sign out").assertIsDisplayed() } +/** + * Waits until at least one node carrying [text] exists in the semantics tree. + * + * This is the existence check (no visibility requirement, so it also passes for content that + * sits below the fold). It is preferred over `assertExists()` because the pinned + * compose-ui-test artifact in this module does not expose that extension. + */ +internal fun ComposeTestRule.awaitTextExists( + text: String, + substring: Boolean = true, + timeoutMillis: Long = IN_MEMORY_TIMEOUT_MS, +) { + waitUntil(timeoutMillis = timeoutMillis) { + onAllNodesWithText(text, substring = substring).fetchSemanticsNodes().isNotEmpty() + } +} + /** * Opens one seeded service screen from the home surface, asserts the screen and its submit * control, runs [onScreen] while it is open, then returns home.