test: add isolated Android instrumentation suite
This commit is contained in:
parent
cf43030104
commit
d80fb231fb
16
README.md
16
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:
|
||||
|
|
|
|||
|
|
@ -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", \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -175,4 +175,11 @@ tasks.withType<JavaCompile>().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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
150
scripts/android-instrumentation-test.sh
Executable file
150
scripts/android-instrumentation-test.sh
Executable 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"
|
||||
19
scripts/ensure-local-android-test-env.sh
Executable file
19
scripts/ensure-local-android-test-env.sh
Executable 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."
|
||||
Loading…
Reference in New Issue
Block a user