name: android-device-e2e # Real-device Android e2e: builds the WebView-debuggable APK, boots an emulator, # and drives the real app via Playwright's Android driver + the on-device agent # smoke. A weekly host-backed schedule gives the lane unattended cadence without # burning an emulator on every PR; workflow_dispatch still exposes the full # backend selector for manual cloud/local investigations. # # IMPORTANT coverage note: the embedded on-device agent (bun + llama) runs on # real arm64 hardware but SIGSEGVs on a stock x86_64 emulator, so the LOCAL # route's full chat round-trip only goes green on a self-hosted arm64 device # runner. On a GitHub-hosted x86_64 emulator this lane proves: build + install + # WebView attach + route navigation, and runs the local smoke as a (currently # expected-to-surface) signal. For green route coverage on the emulator, supply # an Eliza Cloud token (ELIZA_CLOUD_AUTH_TOKEN secret) so the app onboards to a # real cloud agent. # # iOS onboarding-smoke parity (#13580): mobile-build-smoke.yml runs # ios-onboarding-smoke.mjs on every path-gated PR, but the Android # onboarding->home smoke (test:e2e:android:onboarding) is intentionally NOT # per-PR. The iOS smoke boots a simulator on a macOS runner cheaply; the # Android smoke needs a KVM-accelerated x86_64 emulator on a Linux runner, # which is too heavy to boot on every PR (issue #9943). Android # onboarding->home instead runs on three cadences: the label-gated # (ci:device) pr-device-smoke job below, the weekly host-backed schedule, # and workflow_dispatch -- so an onboarding regression is caught on a PR # opt-in or within one weekly cycle rather than never. This is the # documented divergence the parity checkbox in #13580 asks for. on: schedule: # Weekly hosted x86_64 emulator run, backend=host by default below. The # self-hosted arm64 LOCAL route remains separate because bun+llama SIGSEGVs # on the stock hosted x86_64 emulator (see coverage note above). - cron: "17 9 * * 1" workflow_dispatch: inputs: backend: description: "WebView backend for route coverage (cloud|host|local)" type: choice options: [cloud, host, local] default: cloud api-level: description: "Android emulator API level" default: "34" # Reachable on normal develop PRs and release-promotion PRs, but only when # explicitly opted in with the `ci:device` label (issue #9943) — booting an # emulator is slow/heavy, so we never burn a runner on every PR. The # pr-device-smoke job below is gated on that label and runs a MINIMAL slice # (build + install + WebView attach + onboarding→home + one host-backed chat # turn); the full dispatch matrix above is unchanged. pull_request: branches: [develop, main] types: [opened, synchronize, reopened, labeled] concurrency: group: android-device-e2e-${{ github.ref }} cancel-in-progress: true jobs: android-e2e: # Full lane runs on manual dispatch and on the weekly scheduled host-backed # cadence. Never on PR; PRs use the label-gated minimal smoke below. if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 90 permissions: contents: read env: ELIZA_MOBILE_REPO_ROOT: ${{ github.workspace }} ELIZA_WEBVIEW_DEBUG: "1" ELIZA_BUN_RISCV64_OPTIONAL: "1" ELIZA_ANDROID_SKIP_FORK_LLAMA_LIB: "1" ELIZA_ANDROID_BACKEND: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.backend || 'host' }} ELIZA_CLOUD_AUTH_TOKEN: ${{ secrets.ELIZA_CLOUD_AUTH_TOKEN }} steps: - uses: actions/checkout@v4 with: submodules: recursive - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.14" # pinned: repo packageManager is bun@1.4.0, an unpublished version (GH+npm 404) that makes bare setup-bun fail here; match .github/ci-bun-version.json (#15071) - uses: actions/setup-java@v5 with: distribution: temurin java-version: "21" - uses: android-actions/setup-android@v4 - name: Install workspace run: bun install - name: Build WebView-debuggable APK run: bun run --cwd packages/app build:android # KVM enables a usable emulator on GitHub ubuntu runners. - name: Enable KVM 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: Route coverage on emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.api-level || '34' }} arch: x86_64 ram-size: 6144M # avx2/fma passthrough so the (x86) on-device agent doesn't SIGILL. emulator-options: -no-window -no-snapshot -no-boot-anim -gpu swiftshader_indirect -qemu -cpu qemu64,+avx,+avx2,+f16c,+fma script: | set -euo pipefail adb root || true adb shell setenforce 0 || true bun run --cwd packages/app build # ensure web dist mkdir -p packages/app/test-results/android-onboarding-to-home ELIZA_API_PORT=31337 \ ELIZA_PAIRING_DISABLED=1 \ node packages/app-core/scripts/run-node-tsx.mjs \ packages/app-core/scripts/serve-real-local-agent.ts \ > packages/app/test-results/android-onboarding-to-home/host-agent.log 2>&1 & HOST_AGENT_PID=$! trap 'kill "$HOST_AGENT_PID" 2>/dev/null || true' EXIT for i in $(seq 1 90); do if curl -fsS \ -H 'X-ElizaOS-Client-Id: android-onboarding-ci' \ http://127.0.0.1:31337/api/health >/tmp/android-host-agent-health.json; then cat /tmp/android-host-agent-health.json break fi if ! kill -0 "$HOST_AGENT_PID" 2>/dev/null; then echo "Host agent exited before becoming healthy" cat packages/app/test-results/android-onboarding-to-home/host-agent.log exit 1 fi sleep 2 done curl -fsS \ -H 'X-ElizaOS-Client-Id: android-onboarding-ci' \ http://127.0.0.1:31337/api/health >/dev/null ELIZA_ANDROID_BACKEND=host \ ELIZA_ANDROID_REQUIRE_AGENT=1 \ bun run --cwd packages/app test:e2e:android:onboarding if [ "${ELIZA_ANDROID_BACKEND}" = "local" ]; then # LOCAL route is still signal-only on hosted x86_64: the embedded # bun+llama agent is known to require a self-hosted arm64 device # runner. Keep surfacing the artifact, but do not make the hosted # emulator falsely red for that known platform gap. ELIZA_ANDROID_REQUIRE_AGENT=1 \ bun run --cwd packages/app test:e2e:android:routes || true else # Host/cloud route coverage is the scheduled unattended health # signal. Fail red here instead of hiding regressions behind a # permanent signal-only `|| true` leg. ELIZA_ANDROID_REQUIRE_AGENT=0 \ bun run --cwd packages/app test:e2e:android:routes fi ELIZA_ANDROID_BACKEND=host \ ELIZA_ANDROID_REQUIRE_AGENT=1 \ bun run --cwd packages/app test:e2e:android:native-plugin-view # Local on-device agent smoke (surfaces failure loudly; expected to # fail on stock x86 emulator — see coverage note above). bun run --cwd packages/app test:sim:local-chat:android:live || true # Seeded launcher gesture loop (#12377): ≥200 real device swipes/taps # with per-action rail invariants (data-page + AX probe + inertness + # focus), chunked screenrecord + logcat. Signal-only on the x86 # emulator until proven emulator-green; the seed prints for replay. ELIZA_ANDROID_BACKEND=host \ ELIZA_ANDROID_REQUIRE_AGENT=1 \ bun run --cwd packages/app test:e2e:android:launcher-loop || true - name: Cloud provisioning probe run: | if [ -z "${ELIZA_CLOUD_AUTH_TOKEN:-}" ]; then { echo "### Cloud provisioning probe" echo "" echo "skipped: missing ELIZA_CLOUD_AUTH_TOKEN" } >> "$GITHUB_STEP_SUMMARY" echo "Cloud provisioning probe skipped: missing ELIZA_CLOUD_AUTH_TOKEN" exit 0 fi bun run --cwd packages/app test:e2e:android:cloud - uses: actions/upload-artifact@v7 if: always() with: name: android-e2e-artifacts path: | packages/app/test-results/** packages/app/playwright-report/** if-no-files-found: ignore notify-scheduled-failure: needs: android-e2e if: ${{ always() && github.event_name == 'schedule' && needs.android-e2e.result != 'success' }} runs-on: ubuntu-24.04 permissions: contents: read issues: write env: ANDROID_E2E_RESULT: ${{ needs.android-e2e.result }} ELIZA_ANDROID_BACKEND: host steps: - name: Open / update scheduled failure issue uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 with: script: | const title = "Scheduled Android device e2e is failing (#13580)"; const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const artifactsUrl = `${runUrl}#artifacts`; const runDate = new Date().toISOString(); const result = process.env.ANDROID_E2E_RESULT || "unknown"; const body = [ "The weekly host-backed Android device e2e cadence failed.", "", `Run: ${runUrl}`, `Artifacts: ${artifactsUrl}`, `Workflow: \`${context.workflow}\``, `Android job result: \`${result}\``, `Backend: \`${process.env.ELIZA_ANDROID_BACKEND || "host"}\``, `Observed: ${runDate}`, "", "This issue is auto-maintained by `.github/workflows/android-device-e2e.yml` so scheduled failures are visible outside Actions history.", "", "Known #13580 residuals still tracked separately:", "- LOCAL arm64 on-device route needs a self-hosted arm64 runner.", "- Signal-only PR legs should be promoted only after emulator-green evidence exists.", ].join("\n"); const openIssues = await github.paginate(github.rest.issues.listForRepo, { owner: context.repo.owner, repo: context.repo.repo, state: "open", per_page: 100, }); const existing = openIssues.find((issue) => !issue.pull_request && issue.title === title); if (existing) { await github.rest.issues.update({ owner: context.repo.owner, repo: context.repo.repo, issue_number: existing.number, body, }); await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: existing.number, body: `Scheduled Android device e2e failed again (${result}): ${runUrl}`, }); core.info(`Updated scheduled failure issue #${existing.number}`); } else { const { data: created } = await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title, body, }); core.info(`Opened scheduled failure issue #${created.number}`); } # Minimal, label-gated PR device smoke (issue #9943). Reachable on a PR only # when the `ci:device` label is applied, so it never burns an emulator runner # on every PR. Runs the proven-green x86 slice: build the WebView-debuggable # APK, boot the emulator, attach, and drive onboarding→home plus a chat turn # against the host-run agent (the host backend runs the agent on the runner, so # it works on a stock x86 emulator where the embedded on-device agent SIGSEGVs). pr-device-smoke: if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci:device') }} runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 60 env: ELIZA_MOBILE_REPO_ROOT: ${{ github.workspace }} ELIZA_WEBVIEW_DEBUG: "1" ELIZA_BUN_RISCV64_OPTIONAL: "1" ELIZA_ANDROID_SKIP_FORK_LLAMA_LIB: "1" ELIZA_ANDROID_BACKEND: "host" steps: - uses: actions/checkout@v4 with: submodules: recursive - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.14" # pinned: repo packageManager is bun@1.4.0, an unpublished version (GH+npm 404) that makes bare setup-bun fail here; match .github/ci-bun-version.json (#15071) - uses: actions/setup-java@v5 with: distribution: temurin java-version: "21" - uses: android-actions/setup-android@v4 - name: Install workspace run: bun install - name: Build WebView-debuggable APK run: bun run --cwd packages/app build:android - name: Enable KVM 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: Onboarding→home + chat turn on emulator (host agent) uses: reactivecircus/android-emulator-runner@v2 with: api-level: "34" arch: x86_64 ram-size: 6144M emulator-options: -no-window -no-snapshot -no-boot-anim -gpu swiftshader_indirect -qemu -cpu qemu64,+avx,+avx2,+f16c,+fma script: | set -euo pipefail adb root || true adb shell setenforce 0 || true bun run --cwd packages/app build # ensure web dist mkdir -p packages/app/test-results/android-onboarding-to-home ELIZA_API_PORT=31337 \ ELIZA_PAIRING_DISABLED=1 \ node packages/app-core/scripts/run-node-tsx.mjs \ packages/app-core/scripts/serve-real-local-agent.ts \ > packages/app/test-results/android-onboarding-to-home/host-agent.log 2>&1 & HOST_AGENT_PID=$! trap 'kill "$HOST_AGENT_PID" 2>/dev/null || true' EXIT for i in $(seq 1 90); do if curl -fsS \ -H 'X-ElizaOS-Client-Id: android-pr-device-smoke' \ http://127.0.0.1:31337/api/health >/tmp/android-host-agent-health.json; then cat /tmp/android-host-agent-health.json break fi if ! kill -0 "$HOST_AGENT_PID" 2>/dev/null; then echo "Host agent exited before becoming healthy" cat packages/app/test-results/android-onboarding-to-home/host-agent.log exit 1 fi sleep 2 done curl -fsS \ -H 'X-ElizaOS-Client-Id: android-pr-device-smoke' \ http://127.0.0.1:31337/api/health >/dev/null # Onboarding→home + first chat turn, asserted against the host agent. ELIZA_ANDROID_BACKEND=host \ ELIZA_ANDROID_REQUIRE_AGENT=1 \ bun run --cwd packages/app test:e2e:android:onboarding # Native plugin x WebView smoke: proves Capacitor JS calls cross # into Android Kotlin rather than the desktop Chromium bridge shim. # Signal-only (|| true) on the PR lane until it's proven green on the # x86_64 API-34 emulator this job uses — the hard assertion runs in # the workflow_dispatch `android-e2e` job (validated on a real arm64 # device). Do not make it a hard PR gate on unproven-on-emulator # evidence; flip to a hard gate once an emulator-green run is attached. ELIZA_ANDROID_BACKEND=host \ ELIZA_ANDROID_REQUIRE_AGENT=1 \ bun run --cwd packages/app test:e2e:android:native-plugin-view || true # Real OS-level touch swipe on the chat-sheet grabber (#9943): proves # the home→launcher pager gesture fires with real Android touch input # (pointerMouseCount===0), not desktop mouse emulation. Signal-only on # the PR lane for the same reason as above — flip to a hard gate once # emulator-green evidence is attached. ELIZA_ANDROID_BACKEND=host \ ELIZA_ANDROID_REQUIRE_AGENT=1 \ ELIZA_ANDROID_CLEAR_APP_DATA=1 \ bun run --cwd packages/app test:e2e:android:touch-gesture || true # View-runtime telemetry soak (#10196): activates every registered # view through the real eliza:navigate:view channel and asserts # bounded render/heap/cache behavior. Signal-only on the PR lane. ELIZA_ANDROID_BACKEND=host \ ELIZA_ANDROID_REQUIRE_AGENT=1 \ bun run --cwd packages/app test:e2e:android:view-runtime-soak || true - uses: actions/upload-artifact@v7 if: always() with: name: android-pr-device-smoke-artifacts path: | packages/app/test-results/** packages/app/playwright-report/** if-no-files-found: ignore # On-device instrumented (androidTest) gate for the native Capacitor plugins # (issue #9967 "wire into CI" + issue #9943 "on-device never gates a PR"). Until # this lane, the native-plugin Kotlin ran on no test, on no device in CI — the # only coverage was a mocked Capacitor bridge in desktop Chromium. This builds # every native plugin's androidTest, boots a KVM emulator, and runs the real # on-device reads (RoleManager / AudioManager / WifiManager / TelecomManager / # CameraManager / ContactsProvider / content://sms / UsageStatsManager / # FusedLocationProvider) via `connectedDebugAndroidTest` — gradle fails the job # on any test failure. Label-gated (`ci:device`) or dispatch, like the smokes # above, because booting an emulator is heavy. native-plugin-androidtest: if: >- github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'ci:device')) runs-on: ${{ fromJSON(vars.HETZNER_FLEET_ONLINE == 'false' && '["ubuntu-24.04"]' || '["self-hosted","hetzner-robot"]') }} timeout-minutes: 60 env: ELIZA_BUN_RISCV64_OPTIONAL: "1" ELIZA_ANDROID_SKIP_FORK_LLAMA_LIB: "1" steps: - uses: actions/checkout@v4 with: submodules: recursive - uses: oven-sh/setup-bun@v2 with: bun-version: "1.3.14" # pinned: repo packageManager is bun@1.4.0, an unpublished version (GH+npm 404) that makes bare setup-bun fail here; match .github/ci-bun-version.json (#15071) - uses: actions/setup-java@v5 with: distribution: temurin java-version: "21" - uses: android-actions/setup-android@v4 - name: Install workspace run: bun install # Sync the Capacitor Android project so capacitor.settings.gradle points at # this install's @capacitor/android and every plugin module is wired. - name: Prepare Android platform run: bun run --cwd packages/app build:android - name: Enable KVM 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: Native-plugin androidTest on emulator uses: reactivecircus/android-emulator-runner@v2 with: api-level: "34" arch: x86_64 ram-size: 4096M emulator-options: -no-window -no-snapshot -no-boot-anim -gpu swiftshader_indirect script: | set -euo pipefail adb wait-for-device ANDROID_DIR="$GITHUB_WORKSPACE/packages/app-core/platforms/android" GRADLEW="$ANDROID_DIR/gradlew" chmod +x "$GRADLEW" # Device-state orchestration (survives the test-APK install): # - messages: inject a marker SMS so the SMS read-back asserts positively # (it Assume-skips without the marker, so this is what makes it green). # - location: high-accuracy mode (the fused fetch still Assume-skips on a # headless GNSS HAL; the priority-mapping assertion runs regardless). adb emu sms send 15558675309 "probe Eliza-9967-SMS-roundtrip ci" || true adb shell settings put secure location_mode 3 || true # The gate: build + install + run every native-plugin androidTest on the # emulator in one invocation. gradle fails the job on any test failure. # `:app:connectedDebugAndroidTest` adds the app-level assistant/IME/ # assist-surface instrumented tests (#13581) — the RETAIL-path # ElizaAssistantSurfaceInstrumentedTest runs on the plain debug APK # (not assumeSystemEliza-gated), so this app run is not vacuously # green off-AOSP. "$GRADLEW" -p "$ANDROID_DIR" --no-daemon \ :app:connectedDebugAndroidTest \ :elizaos-capacitor-system:connectedDebugAndroidTest \ :elizaos-capacitor-wifi:connectedDebugAndroidTest \ :elizaos-capacitor-phone:connectedDebugAndroidTest \ :elizaos-capacitor-camera:connectedDebugAndroidTest \ :elizaos-capacitor-contacts:connectedDebugAndroidTest \ :elizaos-capacitor-messages:connectedDebugAndroidTest \ :elizaos-capacitor-mobile-signals:connectedDebugAndroidTest \ :elizaos-capacitor-location:connectedDebugAndroidTest # mobile-signals PACKAGE_USAGE_STATS is special-access (no runtime dialog) # and the grant does NOT survive the gate's reinstall, so its usage reads # Assume-skip above. For a positive on-device read: re-install the test APK # the gate built (the gate uninstalls it after running), grant # GET_USAGE_STATS, and re-run the usage tests via am instrument. Require a # clean `OK (N tests)` completion and fail the job on any error/failure. MS_APK="$GITHUB_WORKSPACE/plugins/plugin-native-mobile-signals/android/build/outputs/apk/androidTest/debug/elizaos-capacitor-mobile-signals-debug-androidTest.apk" adb install -r -t "$MS_APK" adb shell appops set ai.eliza.plugins.mobilesignals.test android:get_usage_stats allow MS_OUT="$(adb shell am instrument -w -r \ -e class ai.eliza.plugins.mobilesignals.UsageStatsReaderInstrumentedTest \ ai.eliza.plugins.mobilesignals.test/androidx.test.runner.AndroidJUnitRunner 2>&1)" printf '%s\n' "$MS_OUT" if printf '%s\n' "$MS_OUT" | grep -qE 'FAILURES!!!|INSTRUMENTATION_FAILED|INSTRUMENTATION_RESULT: shortMsg'; then echo "mobile-signals UsageStats assertions failed under granted PACKAGE_USAGE_STATS" exit 1 fi printf '%s\n' "$MS_OUT" | grep -qE 'OK \([0-9]+ test' || { echo "mobile-signals UsageStats run did not complete cleanly" exit 1 } # Assistant-role / voice-IME / assist-key adb verification lane # (#13581). The gradle gate above uninstalled its test APKs, so # install the app APK the Capacitor build produced, then drive the # RUNTIME surface: re-apply the role + IME that `adb install -r` # clears, assert the secure settings, fire `cmd voiceinteraction # show` / `KEYCODE_ASSIST` / the IME deep-link, and assert they land # in MainActivity. `--require-device` makes a missing device fatal # (it is present here, so this only guards against a silent skip). # `--require-engine` is intentionally NOT passed: the emulator carries # no on-device ASR engine, so the IME ASR round-trip legitimately # resolves to the designed ENGINE_OFF state — the lane asserts that # state rather than requiring a transcript. All other assertions # (registration, role, IME selection, deep-link landing) still gate # the job. APP_APK="$GITHUB_WORKSPACE/packages/app-core/platforms/android/app/build/outputs/apk/debug/app-debug.apk" if [ -f "$APP_APK" ]; then adb install -r -t "$APP_APK" else echo "app-debug.apk not found at $APP_APK — build:android should have produced it" exit 1 fi ASSISTANT_ARTIFACT_DIR="$GITHUB_WORKSPACE/packages/app/test-results/android-assistant-verify" mkdir -p "$ASSISTANT_ARTIFACT_DIR" VERIFY_STATUS=0 node "$GITHUB_WORKSPACE/packages/app/scripts/android-assistant-verify.mjs" \ --require-device --json \ | tee "$ASSISTANT_ARTIFACT_DIR/verdict.json" || VERIFY_STATUS=$? adb shell settings get secure voice_interaction_service \ > "$ASSISTANT_ARTIFACT_DIR/voice_interaction_service.txt" || true adb shell settings get secure default_input_method \ > "$ASSISTANT_ARTIFACT_DIR/default_input_method.txt" || true adb shell ime list -s \ > "$ASSISTANT_ARTIFACT_DIR/enabled_imes.txt" || true adb shell cmd role holders android.app.role.ASSISTANT \ > "$ASSISTANT_ARTIFACT_DIR/assistant_role_holders.txt" || true adb shell dumpsys activity activities \ > "$ASSISTANT_ARTIFACT_DIR/dumpsys-activity.txt" || true adb logcat -d -v brief \ > "$ASSISTANT_ARTIFACT_DIR/logcat.txt" || true exit "$VERIFY_STATUS" - uses: actions/upload-artifact@v7 if: always() with: name: native-plugin-androidtest-reports path: | plugins/plugin-native-*/android/build/reports/androidTests/** plugins/plugin-native-*/android/build/outputs/androidTest-results/** packages/app/test-results/android-assistant-verify/** if-no-files-found: ignore