Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.example.suas.demo

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<DemoRootActivity>()

@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.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.awaitTextExists("Demo only: advance status")
}

composeRule.onNodeWithText("Transportation", substring = false).performScrollTo()
.assertIsDisplayed()
}
}
109 changes: 109 additions & 0 deletions app/src/androidTestDebug/java/com/example/suas/demo/LauncherJourney.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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()
}

/**
* 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.
*/
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)
}
Original file line number Diff line number Diff line change
@@ -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<LocalRootActivity>()

@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()
}
}
Loading