test: add isolated Android instrumentation suite

This commit is contained in:
SimpleTest 2026-07-19 01:55:39 +03:00
parent cf43030104
commit d80fb231fb
11 changed files with 781 additions and 12 deletions

View File

@ -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 networks are removed automatically; the normal `who_need_help` Compose project
is not recreated. 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 ## First administrator
Register and confirm the first account, then explicitly bootstrap it: Register and confirm the first account, then explicitly bootstrap it:

View File

@ -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_DEBUG_BASE_URL=${WNH_DEBUG_BASE_URL}" \
"-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \ "-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_MS}" \
"-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \ "-PWNH_TRACKING_HTTP_TIMEOUT_MS=${WNH_TRACKING_HTTP_TIMEOUT_MS}" \
testDebugUnitTest lintDebug assembleDebug testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
FROM android-base AS emulator FROM android-base AS emulator
@ -89,6 +89,9 @@ RUN echo no | avdmanager create avd \
COPY --from=android-sdk --chown=gradle:gradle \ COPY --from=android-sdk --chown=gradle:gradle \
/workspace/android/app/build/outputs/apk/debug/app-debug.apk \ /workspace/android/app/build/outputs/apk/debug/app-debug.apk \
/opt/who-need-help/who-need-help-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", \ CMD ["/opt/android-sdk/emulator/emulator", \
"-avd", "who_need_help_api37", \ "-avd", "who_need_help_api37", \

View File

@ -69,14 +69,39 @@ From the repository root:
sha256sum android/dist/who-need-help-debug.apk sha256sum android/dist/who-need-help-debug.apk
``` ```
The Docker build runs JVM unit tests, Android lint, and `assembleDebug` before it The Docker build runs JVM unit tests, Android lint, `assembleDebug`, and
exports the APK and lint report. `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 ## Emulator verification
The optional `emulator` target contains the API 37.0 Google APIs x86_64 system 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 image. Manual verification against the local Compose application requires KVM
build argument, then expose the Compose proxy to Android with `adb reverse`: and host networking. Pass the same `.env` value as a build argument, then
expose the Compose proxy to Android with `adb reverse`:
```sh ```sh
set -a set -a

View File

@ -175,4 +175,11 @@ tasks.withType<JavaCompile>().configureEach {
dependencies { dependencies {
implementation("androidx.activity:activity:1.13.0") implementation("androidx.activity:activity:1.13.0")
testImplementation("junit:junit:4.13.2") 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")
} }

View File

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

View File

@ -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<RecordedRequest> 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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
return "<!doctype html><html><head>"
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<title>Who Need Help Android fixture</title></head><body>"
+ "<h1 id=\"marker\">loaded:" + escapedPath + "</h1>"
+ "<button id=\"location\" onclick=\"navigator.geolocation.getCurrentPosition("
+ "()=>document.getElementById('marker').textContent='location-granted',"
+ "()=>document.getElementById('marker').textContent='location-denied')\">"
+ "Request location</button>"
+ "</body></html>";
}
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();
}
}

View File

@ -57,6 +57,10 @@ release feeds reported those same versions as current during verification.
| compileSdk / targetSdk | 37 / 37 | | compileSdk / targetSdk | 37 / 37 |
| Android Build Tools | 37.0.0 | | Android Build Tools | 37.0.0 |
| AndroidX Activity | 1.13.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 | | kubectl | 1.36.2 |
| kind | 0.32.0 | | kind | 0.32.0 |
| Helm | 4.2.3 | | 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) - [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 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) - [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) - [Gradle release notes](https://docs.gradle.org/current/release-notes.html)
- [kubectl releases](https://dl.k8s.io/release/stable.txt) - [kubectl releases](https://dl.k8s.io/release/stable.txt)
- [kind releases](https://github.com/kubernetes-sigs/kind/releases) - [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 docker run --rm who-need-help:node-deps npm outdated --json
./scripts/test.sh ./scripts/test.sh
./scripts/android-build.sh ./scripts/android-build.sh
./scripts/android-instrumentation-test.sh
``` ```

View File

@ -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 - Browser console errors, page errors, and unexpected failed requests are
test failures. Playwright traces, screenshots, video, JSON/HTML reports, and test failures. Playwright traces, screenshots, video, JSON/HTML reports, and
Compose logs are retained in ignored output on failure. Compose logs are retained in ignored output on failure.
- Android instrumentation and the remaining rows above are still pending. - The isolated API 37 Android suite passes five device tests: missing and
Browser accessibility, responsive, offline, and reconnect checks remain part granted location permission, same-origin deep-link routing across Activity
of their respective later rows; this document is not a completion claim. 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.

View File

@ -34,10 +34,13 @@ results from product limits and unknown production properties.
observed database constraint changed from exactly one of 3 urgent-help observed database constraint changed from exactly one of 3 urgent-help
targets to exactly one of 5 urgent-help/Activity targets. targets to exactly one of 5 urgent-help/Activity targets.
- `mix format --check-formatted`: passed in the final run. - `mix format --check-formatted`: passed in the final run.
- Android local Docker build target: `testDebugUnitTest`, `lintDebug`, and - Android local Docker build target: `testDebugUnitTest`, `lintDebug`,
`assembleDebug` passed. The public-staging target ran the unit tests plus `assembleDebug`, and `assembleDebugAndroidTest` passed. The isolated API 37
`lintStaging` and `assembleStaging`; both final lint reports contain no errors runner then passed 5/5 instrumentation tests for denied/granted location
or warnings. 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 - Release Android guard: a release build without `WNH_BASE_URL` failed as
intended; release configuration accepts only an explicit HTTPS origin. intended; release configuration accepts only an explicit HTTPS origin.
- Helm lint, template rendering, server-side dry-run, rollout waits, readiness - 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/final-image-smoke.png`
- `output/android/foreground-notification.png` - `output/android/foreground-notification.png`
- `output/android/staging-deep-link-safety.png` - `output/android/staging-deep-link-safety.png`
- `output/android-instrumentation/20260718225400-2694340/results.txt`
Android artifact: Android artifact:

View File

@ -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"

View File

@ -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."