From d80fb231fbdbefd1f29277747b86df2c2d6f90a6 Mon Sep 17 00:00:00 2001 From: SimpleTest Date: Sun, 19 Jul 2026 01:55:39 +0300 Subject: [PATCH] test: add isolated Android instrumentation suite --- README.md | 16 + android/Dockerfile | 5 +- android/README.md | 33 +- android/app/build.gradle.kts | 7 + .../mobile/AndroidClientInstrumentedTest.java | 305 ++++++++++++++++++ .../whoneedhelp/mobile/FixtureHttpServer.java | 227 +++++++++++++ docs/dependency-baseline.md | 7 + docs/local-hardening-plan.md | 12 +- docs/verification.md | 12 +- scripts/android-instrumentation-test.sh | 150 +++++++++ scripts/ensure-local-android-test-env.sh | 19 ++ 11 files changed, 781 insertions(+), 12 deletions(-) create mode 100644 android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java create mode 100644 android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java create mode 100755 scripts/android-instrumentation-test.sh create mode 100755 scripts/ensure-local-android-test-env.sh diff --git a/README.md b/README.md index 8232b16..c30f45e 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,22 @@ It retains traces, screenshots, video, and Compose logs under the ignored networks are removed automatically; the normal `who_need_help` Compose project is not recreated. +The Android device suite builds a dedicated debug and instrumentation APK, +boots an API 37 emulator in an isolated container without external networking, +and serves its HTTP fixture only on device loopback: + +```bash +./scripts/android-instrumentation-test.sh +``` + +On first run it generates the ignored `.env.android-test` with a randomized +device-loopback origin and mode `0600`. The suite covers denied and granted +location permission, same-origin deep links, Activity recreation, foreground +location upload, the persistent notification Stop action, and a disconnected +Stop request with visible retry state. Results and failure diagnostics are +retained under ignored `output/android-instrumentation/`; the exact emulator +container and one-run image are removed automatically. + ## First administrator Register and confirm the first account, then explicitly bootstrap it: diff --git a/android/Dockerfile b/android/Dockerfile index 94d16e8..5fc019a 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -45,7 +45,7 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ "-PWNH_DEBUG_BASE_URL=${WNH_DEBUG_BASE_URL}" \ "-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \ "-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \ - testDebugUnitTest lintDebug assembleDebug + testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest FROM android-base AS emulator @@ -89,6 +89,9 @@ RUN echo no | avdmanager create avd \ COPY --from=android-sdk --chown=gradle:gradle \ /workspace/android/app/build/outputs/apk/debug/app-debug.apk \ /opt/who-need-help/who-need-help-debug.apk +COPY --from=android-sdk --chown=gradle:gradle \ + /workspace/android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ + /opt/who-need-help/who-need-help-debug-androidTest.apk CMD ["/opt/android-sdk/emulator/emulator", \ "-avd", "who_need_help_api37", \ diff --git a/android/README.md b/android/README.md index 82b6b2d..76cf5dc 100644 --- a/android/README.md +++ b/android/README.md @@ -69,14 +69,39 @@ From the repository root: sha256sum android/dist/who-need-help-debug.apk ``` -The Docker build runs JVM unit tests, Android lint, and `assembleDebug` before it -exports the APK and lint report. +The Docker build runs JVM unit tests, Android lint, `assembleDebug`, and +`assembleDebugAndroidTest` before it exports the application APK and lint +report. + +## Automated API 37 device tests + +Run the complete device suite from the repository root: + +```sh +./scripts/android-instrumentation-test.sh +``` + +The command requires `/dev/kvm`. It generates an ignored +`.env.android-test` once with a randomized `http://127.0.0.1:PORT` origin and +mode `0600`; the application and its in-process fixture server both derive the +origin from that file. It then builds both APKs, boots a fresh API 37 emulator +container without external networking, injects emulator coordinates, and runs +`AndroidJUnitRunner`. + +The tests cover the missing-location-permission boundary, the WebView-triggered +Android permission dialog, same-origin deep-link routing across Activity +recreation, foreground location upload, the persistent notification Stop +action, and a disconnected Stop request with visible retry state. Results are +stored under ignored `output/android-instrumentation/`. On failure, logcat, +service state, and emulator logs are retained; the exact container and +one-run image are removed in either outcome. ## Emulator verification The optional `emulator` target contains the API 37.0 Google APIs x86_64 system -image. It requires KVM and host networking. Pass the same `.env` value as a -build argument, then expose the Compose proxy to Android with `adb reverse`: +image. Manual verification against the local Compose application requires KVM +and host networking. Pass the same `.env` value as a build argument, then +expose the Compose proxy to Android with `adb reverse`: ```sh set -a diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 0afae54..2a29d2d 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -175,4 +175,11 @@ tasks.withType().configureEach { dependencies { implementation("androidx.activity:activity:1.13.0") testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test:core:1.7.0") + androidTestImplementation("androidx.test:runner:1.7.0") + androidTestImplementation("androidx.test:rules:1.7.0") + androidTestImplementation("androidx.test.ext:junit:1.3.0") + androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0") + androidTestImplementation("androidx.test.espresso:espresso-web:3.7.0") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.4.0") } diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java new file mode 100644 index 0000000..919b84c --- /dev/null +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java @@ -0,0 +1,305 @@ +package org.whoneedhelp.mobile; + +import static androidx.test.espresso.web.assertion.WebViewAssertions.webMatches; +import static androidx.test.espresso.web.sugar.Web.onWebView; +import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.getText; +import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.Manifest; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObject2; +import androidx.test.uiautomator.Until; +import androidx.test.espresso.web.webdriver.Locator; + +import org.junit.After; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.MethodSorters; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@RunWith(AndroidJUnit4.class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public final class AndroidClientInstrumentedTest { + private static final String LOCATION_PERMISSION_CONTROLLER = + "com.android.permissioncontroller"; + private static final long UI_TIMEOUT_MS = 10_000; + + private Context context; + private UiDevice device; + private FixtureHttpServer server; + + @Before + public void setUp() throws Exception { + context = ApplicationProvider.getApplicationContext(); + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()); + context.stopService(new Intent(context, TrackingService.class)); + context.getSystemService(NotificationManager.class).cancelAll(); + server = new FixtureHttpServer(); + } + + @After + public void tearDown() throws Exception { + context.stopService(new Intent(context, TrackingService.class)); + context.getSystemService(NotificationManager.class).cancelAll(); + device.pressBack(); + server.close(); + } + + @Test + public void test01TrackingWithoutLocationPermissionStopsLocally() throws Exception { + assertFalse(hasLocationPermission()); + + try (ActivityScenario scenario = launch("/permission-boundary")) { + scenario.onActivity(activity -> + activity.startForegroundService( + TrackingService.startIntent( + activity, + UUID.randomUUID().toString(), + "csrf-without-permission", + "_session=without-permission" + ) + ) + ); + + waitForServiceState(false); + assertFalse( + device.hasObject(By.text(context.getString(R.string.tracking_active))) + ); + } + } + + @Test + public void test02WebGeolocationRequestsAndGrantsRuntimePermission() { + assertFalse(hasLocationPermission()); + + try ( + ActivityScenario scenario = launch("/location-permission") + ) { + onWebView() + .withElement(findElement(Locator.ID, "location")) + .perform(webClick()); + + UiObject2 allow = device.wait( + Until.findObject( + By.res( + LOCATION_PERMISSION_CONTROLLER, + "permission_allow_foreground_only_button" + ) + ), + UI_TIMEOUT_MS + ); + + if (allow == null) { + allow = device.wait( + Until.findObject(By.textContains("While using the app")), + UI_TIMEOUT_MS + ); + } + + assertNotNull("Android location permission prompt was not shown", allow); + allow.click(); + + assertTrue(hasLocationPermission()); + } + } + + @Test + public void test03SameOriginDeepLinkRouteSurvivesRecreation() { + try (ActivityScenario scenario = launch("/requests/deep-link-check")) { + onWebView() + .withElement(findElement(Locator.ID, "marker")) + .check( + webMatches( + getText(), + containsString("loaded:/requests/deep-link-check") + ) + ); + + scenario.recreate(); + + onWebView() + .withElement(findElement(Locator.ID, "marker")) + .check( + webMatches( + getText(), + containsString("loaded:/requests/deep-link-check") + ) + ); + } + } + + @Test + public void test04ForegroundTrackingPostsLocationAndNotificationStop() throws Exception { + grantTrackingPermissions(); + String assignmentId = UUID.randomUUID().toString(); + + try (ActivityScenario scenario = launch("/tracking-success")) { + startTracking(scenario, assignmentId); + waitForServiceState(true); + + FixtureHttpServer.RecordedRequest position = server.awaitRequestEndingWith( + "/mobile/tracking/" + assignmentId + "/position", + 15, + TimeUnit.SECONDS + ); + assertNotNull("Foreground service did not upload an emulator location", position); + assertTrue(position.body.contains("\"latitude\":")); + assertTrue(position.body.contains("\"longitude\":")); + + openNotificationAndClickStop(); + + FixtureHttpServer.RecordedRequest stop = server.awaitRequestEndingWith( + "/mobile/tracking/" + assignmentId + "/stop", + 10, + TimeUnit.SECONDS + ); + assertNotNull("Notification Stop did not call the server boundary", stop); + waitForServiceState(false); + assertTrue( + device.wait( + Until.gone(By.text(context.getString(R.string.tracking_active))), + UI_TIMEOUT_MS + ) + ); + } + } + + @Test + public void test05NetworkFailureKeepsVisibleRetryState() throws Exception { + grantTrackingPermissions(); + server.disconnectStopRequest(); + String assignmentId = UUID.randomUUID().toString(); + + try (ActivityScenario scenario = launch("/tracking-network-failure")) { + startTracking(scenario, assignmentId); + waitForServiceState(true); + openNotificationAndClickStop(); + + FixtureHttpServer.RecordedRequest stop = server.awaitRequestEndingWith( + "/mobile/tracking/" + assignmentId + "/stop", + 10, + TimeUnit.SECONDS + ); + assertNotNull("The disconnected Stop request did not reach the fixture", stop); + assertTrue( + device.wait( + Until.hasObject( + By.text(context.getString(R.string.tracking_stop_failed)) + ), + UI_TIMEOUT_MS + ) + ); + assertTrue(serviceIsRunning()); + } + } + + private ActivityScenario launch(String path) { + Intent intent = new Intent( + Intent.ACTION_VIEW, + Uri.parse(BuildConfig.BASE_URL + path), + context, + MainActivity.class + ); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return ActivityScenario.launch(intent); + } + + private void startTracking( + ActivityScenario scenario, + String assignmentId + ) { + scenario.onActivity(activity -> + activity.startForegroundService( + TrackingService.startIntent( + activity, + assignmentId, + "instrumentation-csrf", + "_session=instrumentation" + ) + ) + ); + } + + private void openNotificationAndClickStop() { + device.openNotification(); + UiObject2 active = device.wait( + Until.findObject(By.text(context.getString(R.string.tracking_active))), + UI_TIMEOUT_MS + ); + assertNotNull("Foreground tracking notification was not visible", active); + UiObject2 stop = device.wait( + Until.findObject(By.text(context.getString(R.string.tracking_stop_action))), + UI_TIMEOUT_MS + ); + assertNotNull("Foreground tracking notification had no Stop action", stop); + stop.click(); + } + + private boolean hasLocationPermission() { + return context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) + == android.content.pm.PackageManager.PERMISSION_GRANTED + || context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) + == android.content.pm.PackageManager.PERMISSION_GRANTED; + } + + private void grantTrackingPermissions() throws Exception { + String packageName = context.getPackageName(); + device.executeShellCommand( + "pm grant " + packageName + " " + Manifest.permission.ACCESS_FINE_LOCATION + ); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + device.executeShellCommand( + "pm grant " + packageName + " " + Manifest.permission.POST_NOTIFICATIONS + ); + } + + assertTrue(hasLocationPermission()); + } + + private void waitForServiceState(boolean expected) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + + while (System.nanoTime() < deadline) { + if (serviceIsRunning() == expected) { + return; + } + + Thread.sleep(100); + } + + assertTrue( + "TrackingService running state did not become " + expected, + serviceIsRunning() == expected + ); + } + + private boolean serviceIsRunning() throws Exception { + return device + .executeShellCommand( + "dumpsys activity services " + context.getPackageName() + ) + .contains(TrackingService.class.getName()); + } + +} diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java new file mode 100644 index 0000000..7f8f531 --- /dev/null +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java @@ -0,0 +1,227 @@ +package org.whoneedhelp.mobile; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +final class FixtureHttpServer implements Closeable { + static final class RecordedRequest { + final String method; + final String path; + final String body; + + RecordedRequest(String method, String path, String body) { + this.method = method; + this.path = path; + this.body = body; + } + } + + private final ServerSocket serverSocket; + private final ExecutorService executor = Executors.newCachedThreadPool(); + private final List requests = + Collections.synchronizedList(new ArrayList<>()); + private volatile boolean running = true; + private volatile boolean disconnectStopRequest; + + FixtureHttpServer() throws IOException { + URI base = URI.create(BuildConfig.BASE_URL); + + if ( + !"http".equals(base.getScheme()) + || !"127.0.0.1".equals(base.getHost()) + || base.getPort() < 1 + ) { + throw new IllegalStateException( + "Instrumentation BASE_URL must be an isolated http://127.0.0.1:PORT origin" + ); + } + + serverSocket = new ServerSocket(); + serverSocket.setReuseAddress(true); + serverSocket.bind( + new InetSocketAddress(InetAddress.getByName(base.getHost()), base.getPort()) + ); + executor.execute(this::acceptLoop); + } + + void disconnectStopRequest() { + disconnectStopRequest = true; + } + + RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit) + throws InterruptedException { + long deadline = System.nanoTime() + unit.toNanos(timeout); + + while (System.nanoTime() < deadline) { + synchronized (requests) { + for (RecordedRequest request : requests) { + if (request.path.endsWith(suffix)) { + return request; + } + } + } + + Thread.sleep(50); + } + + return null; + } + + @Override + public void close() throws IOException { + running = false; + serverSocket.close(); + executor.shutdownNow(); + + try { + executor.awaitTermination(5, TimeUnit.SECONDS); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + } + + private void acceptLoop() { + while (running) { + try { + Socket socket = serverSocket.accept(); + executor.execute(() -> handle(socket)); + } catch (IOException exception) { + if (running) { + throw new IllegalStateException("Fixture HTTP accept failed", exception); + } + } + } + } + + private void handle(Socket socket) { + try (Socket connection = socket) { + connection.setSoTimeout(5_000); + BufferedInputStream input = new BufferedInputStream(connection.getInputStream()); + String requestLine = readLine(input); + + if (requestLine == null || requestLine.trim().isEmpty()) { + return; + } + + String[] parts = requestLine.split(" ", 3); + + if (parts.length != 3) { + return; + } + + int contentLength = 0; + String header; + + while ((header = readLine(input)) != null && !header.isEmpty()) { + String lower = header.toLowerCase(Locale.ROOT); + + if (lower.startsWith("content-length:")) { + contentLength = Integer.parseInt( + header.substring(header.indexOf(':') + 1).trim() + ); + } + } + + byte[] bodyBytes = input.readNBytes(contentLength); + RecordedRequest request = new RecordedRequest( + parts[0], + parts[1], + new String(bodyBytes, StandardCharsets.UTF_8) + ); + requests.add(request); + + if (disconnectStopRequest && request.path.endsWith("/stop")) { + return; + } + + if ("GET".equals(request.method)) { + writeResponse( + connection.getOutputStream(), + 200, + "text/html; charset=utf-8", + fixtureHtml(request.path).getBytes(StandardCharsets.UTF_8) + ); + } else { + writeResponse( + connection.getOutputStream(), + 204, + "application/json", + new byte[0] + ); + } + } catch (IOException ignored) { + // A deliberately disconnected request is part of the failure test. + } + } + + private static String fixtureHtml(String path) { + String escapedPath = path + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + + return "" + + "" + + "Who Need Help Android fixture" + + "

loaded:" + escapedPath + "

" + + "" + + ""; + } + + private static String readLine(BufferedInputStream input) throws IOException { + ByteArrayOutputStream line = new ByteArrayOutputStream(); + int value; + + while ((value = input.read()) != -1) { + if (value == '\n') { + break; + } + + if (value != '\r') { + line.write(value); + } + } + + if (value == -1 && line.size() == 0) { + return null; + } + + return line.toString(StandardCharsets.US_ASCII); + } + + private static void writeResponse( + OutputStream output, + int status, + String contentType, + byte[] body + ) throws IOException { + String reason = status == 200 ? "OK" : "No Content"; + String headers = "HTTP/1.1 " + status + " " + reason + "\r\n" + + "Content-Type: " + contentType + "\r\n" + + "Content-Length: " + body.length + "\r\n" + + "Connection: close\r\n\r\n"; + output.write(headers.getBytes(StandardCharsets.US_ASCII)); + output.write(body); + output.flush(); + } +} diff --git a/docs/dependency-baseline.md b/docs/dependency-baseline.md index 8fbc611..e3ae2a9 100644 --- a/docs/dependency-baseline.md +++ b/docs/dependency-baseline.md @@ -57,6 +57,10 @@ release feeds reported those same versions as current during verification. | compileSdk / targetSdk | 37 / 37 | | Android Build Tools | 37.0.0 | | AndroidX Activity | 1.13.0 | +| AndroidX Test core/runner/rules | 1.7.0 | +| AndroidX Test JUnit extension | 1.3.0 | +| Espresso core/web | 3.7.0 | +| UI Automator | 2.4.0 | | kubectl | 1.36.2 | | kind | 0.32.0 | | Helm | 4.2.3 | @@ -78,6 +82,8 @@ because the official SDK channel identifies it as a QPR beta. - [k6 releases](https://github.com/grafana/k6/releases) - [Android Gradle Plugin 9.3.0 release notes](https://developer.android.com/build/releases/agp-9-3-0-release-notes) - [Android 17 SDK setup](https://developer.android.com/about/versions/17/setup-sdk) +- [Build instrumented tests](https://developer.android.com/training/testing/instrumented-tests) +- [Run Android tests from the command line](https://developer.android.com/studio/test/command-line) - [Gradle release notes](https://docs.gradle.org/current/release-notes.html) - [kubectl releases](https://dl.k8s.io/release/stable.txt) - [kind releases](https://github.com/kubernetes-sigs/kind/releases) @@ -95,4 +101,5 @@ docker run --rm who-need-help:node-deps npm --version docker run --rm who-need-help:node-deps npm outdated --json ./scripts/test.sh ./scripts/android-build.sh +./scripts/android-instrumentation-test.sh ``` diff --git a/docs/local-hardening-plan.md b/docs/local-hardening-plan.md index e37bb76..b3a569f 100644 --- a/docs/local-hardening-plan.md +++ b/docs/local-hardening-plan.md @@ -38,6 +38,12 @@ The goal remains open while any row lacks reproducible local evidence. - Browser console errors, page errors, and unexpected failed requests are test failures. Playwright traces, screenshots, video, JSON/HTML reports, and Compose logs are retained in ignored output on failure. -- Android instrumentation and the remaining rows above are still pending. - Browser accessibility, responsive, offline, and reconnect checks remain part - of their respective later rows; this document is not a completion claim. +- The isolated API 37 Android suite passes five device tests: missing and + granted location permission, same-origin deep-link routing across Activity + recreation, native foreground location upload, notification Stop with remote + cleanup, and a disconnected Stop request with visible retry state. It uses an + in-process loopback fixture, runs its emulator without external networking, + retains diagnostics on failure, and removes its exact container and image. +- The remaining rows above are still pending. Browser accessibility, + responsive, offline, and reconnect checks remain part of their respective + later rows; this document is not a completion claim. diff --git a/docs/verification.md b/docs/verification.md index 31caf9b..3bee3e0 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -34,10 +34,13 @@ results from product limits and unknown production properties. observed database constraint changed from exactly one of 3 urgent-help targets to exactly one of 5 urgent-help/Activity targets. - `mix format --check-formatted`: passed in the final run. -- Android local Docker build target: `testDebugUnitTest`, `lintDebug`, and - `assembleDebug` passed. The public-staging target ran the unit tests plus - `lintStaging` and `assembleStaging`; both final lint reports contain no errors - or warnings. +- Android local Docker build target: `testDebugUnitTest`, `lintDebug`, + `assembleDebug`, and `assembleDebugAndroidTest` passed. The isolated API 37 + runner then passed 5/5 instrumentation tests for denied/granted location + permission, deep-link lifecycle, foreground location upload, notification + Stop, and disconnected-Stop retry state. The public-staging target ran the + unit tests plus `lintStaging` and `assembleStaging`; both previously recorded + lint reports contain no errors or warnings. - Release Android guard: a release build without `WNH_BASE_URL` failed as intended; release configuration accepts only an explicit HTTPS origin. - Helm lint, template rendering, server-side dry-run, rollout waits, readiness @@ -53,6 +56,7 @@ Local generated evidence (ignored by Git): - `output/android/final-image-smoke.png` - `output/android/foreground-notification.png` - `output/android/staging-deep-link-safety.png` +- `output/android-instrumentation/20260718225400-2694340/results.txt` Android artifact: diff --git a/scripts/android-instrumentation-test.sh b/scripts/android-instrumentation-test.sh new file mode 100755 index 0000000..b520842 --- /dev/null +++ b/scripts/android-instrumentation-test.sh @@ -0,0 +1,150 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +ANDROID_ENV="$ROOT/.env" +TEST_ENV="$ROOT/.env.android-test" +run_id=$(date -u +%Y%m%d%H%M%S)-$$ +image="who-need-help-android:instrumentation-$run_id" +container="who-need-help-android-instrumentation-$run_id" +output="$ROOT/output/android-instrumentation/$run_id" +geo_pid="" + +if [ ! -e /dev/kvm ]; then + echo "/dev/kvm is required for the containerized Android emulator." >&2 + exit 1 +fi + +if [ ! -f "$ANDROID_ENV" ]; then + echo "Missing $ANDROID_ENV. Copy .env.example to .env and configure Android timings." >&2 + exit 1 +fi + +"$ROOT/scripts/ensure-local-android-test-env.sh" + +set -a +. "$ANDROID_ENV" +. "$TEST_ENV" +set +a + +: "${WNH_ANDROID_TEST_BASE_URL:?Set WNH_ANDROID_TEST_BASE_URL in .env.android-test}" +: "${WNH_TRACKING_MIN_TIME_MS:?Set WNH_TRACKING_MIN_TIME_MS in .env}" +: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?Set WNH_TRACKING_HTTP_TIMEOUT_MS in .env}" + +android_test_port=${WNH_ANDROID_TEST_BASE_URL#http://127.0.0.1:} + +if [ "$android_test_port" = "$WNH_ANDROID_TEST_BASE_URL" ]; then + echo "WNH_ANDROID_TEST_BASE_URL must be an http://127.0.0.1:PORT origin." >&2 + exit 1 +fi + +case "$android_test_port" in + ""|*[!0-9]*) + echo "WNH_ANDROID_TEST_BASE_URL must contain only a numeric port." >&2 + exit 1 + ;; +esac + +if [ "$android_test_port" -lt 1 ] || [ "$android_test_port" -gt 65535 ]; then + echo "WNH_ANDROID_TEST_BASE_URL port must be between 1 and 65535." >&2 + exit 1 +fi + +mkdir -p "$output" + +cleanup() { + status=$? + + if [ -n "$geo_pid" ]; then + kill "$geo_pid" >/dev/null 2>&1 || true + wait "$geo_pid" >/dev/null 2>&1 || true + fi + + if [ "$status" -ne 0 ] && docker inspect "$container" >/dev/null 2>&1; then + docker exec "$container" adb logcat -d > "$output/logcat.txt" 2>&1 || true + docker exec "$container" adb shell dumpsys activity services \ + org.whoneedhelp.mobile.debug > "$output/services.txt" 2>&1 || true + docker logs "$container" > "$output/emulator.log" 2>&1 || true + fi + + docker rm -f "$container" >/dev/null 2>&1 || true + docker image rm "$image" >/dev/null 2>&1 || true + exit "$status" +} + +trap cleanup EXIT HUP INT TERM + +docker build \ + --build-arg "WNH_DEBUG_BASE_URL=$WNH_ANDROID_TEST_BASE_URL" \ + --build-arg "WNH_TRACKING_MIN_TIME_MS=$WNH_TRACKING_MIN_TIME_MS" \ + --build-arg "WNH_TRACKING_HTTP_TIMEOUT_MS=$WNH_TRACKING_HTTP_TIMEOUT_MS" \ + --target emulator \ + -t "$image" \ + "$ROOT/android" + +docker run -d \ + --name "$container" \ + --device /dev/kvm \ + --network none \ + "$image" > "$output/container-id.txt" + +docker exec "$container" adb wait-for-device + +booted="" +attempt=0 +while [ "$attempt" -lt 90 ]; do + booted=$(docker exec "$container" adb shell getprop sys.boot_completed 2>/dev/null \ + | tr -d '\r') + + if [ "$booted" = "1" ]; then + break + fi + + attempt=$((attempt + 1)) + sleep 2 +done + +if [ "$booted" != "1" ]; then + echo "Android emulator did not finish booting." >&2 + exit 1 +fi + +docker exec "$container" adb shell input keyevent 82 +docker exec "$container" adb shell settings put global window_animation_scale 0 +docker exec "$container" adb shell settings put global transition_animation_scale 0 +docker exec "$container" adb shell settings put global animator_duration_scale 0 +docker exec "$container" adb shell cmd location set-location-enabled true +docker exec "$container" adb emu geo fix -122.084000 37.422000 +docker exec "$container" adb install -r \ + /opt/who-need-help/who-need-help-debug.apk +docker exec "$container" adb install -r \ + /opt/who-need-help/who-need-help-debug-androidTest.apk + +docker exec "$container" adb shell pm list instrumentation \ + > "$output/instrumentation.txt" + +( + while docker inspect "$container" >/dev/null 2>&1; do + docker exec "$container" adb emu geo fix -122.084000 37.422000 \ + >/dev/null 2>&1 || true + sleep 2 + done +) & +geo_pid=$! + +set +e +docker exec "$container" adb shell am instrument -w -r \ + org.whoneedhelp.mobile.debug.test/androidx.test.runner.AndroidJUnitRunner \ + > "$output/results.txt" 2>&1 +instrumentation_status=$? +set -e +cat "$output/results.txt" + +if [ "$instrumentation_status" -ne 0 ] \ + || ! grep -Eq '^OK \([0-9]+ tests?\)$' "$output/results.txt" \ + || grep -Eq 'FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed' "$output/results.txt"; then + echo "Android instrumentation failed; diagnostics are retained in $output." >&2 + exit 1 +fi + +echo "Android instrumentation passed. Evidence: $output" diff --git a/scripts/ensure-local-android-test-env.sh b/scripts/ensure-local-android-test-env.sh new file mode 100755 index 0000000..aced2a3 --- /dev/null +++ b/scripts/ensure-local-android-test-env.sh @@ -0,0 +1,19 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +target="$ROOT/.env.android-test" + +if [ -f "$target" ]; then + echo ".env.android-test already exists; no setting was changed." + exit 0 +fi + +port=$(od -An -N4 -tu4 /dev/urandom | awk '{print 30000 + ($1 % 20000)}') +umask 077 +{ + printf 'WNH_ANDROID_TEST_BASE_URL=http://127.0.0.1:%s\n' "$port" +} > "$target" + +chmod 600 "$target" +echo "Generated .env.android-test with an isolated device-loopback origin and mode 0600."