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
|
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:
|
||||||
|
|
|
||||||
|
|
@ -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", \
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| 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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
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