feat: verify Android staging interoperability

This commit is contained in:
SimpleTest 2026-07-20 02:30:57 +03:00
parent 7c1d24d356
commit b96d443a96
18 changed files with 1472 additions and 27 deletions

View File

@ -294,6 +294,23 @@ Results and failure diagnostics are retained by API under ignored
`output/android-instrumentation/`; the exact emulator container and one-run `output/android-instrumentation/`; the exact emulator container and one-run
image are removed automatically. image are removed automatically.
The public-staging variant and its instrumentation APK use the explicit HTTPS
origin from the ignored `.env`. The smoke probe checks rendered WebView DOM on
the home and Safety routes. The cross-client probe uses run-scoped users and a
matched medicine request to verify Android login, private chat in both
directions, foreground location sharing, browser marker appearance and
removal, and exact database cleanup:
```bash
./scripts/android-staging-build.sh
./scripts/android-staging-smoke.sh
./scripts/android-browser-staging-e2e.sh
```
The cross-client script refuses an unexpected database, uses a unique fixture
prefix and manifest, and compares counts across 19 application tables before
and after cleanup. It does not delete unrelated records.
## 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

@ -148,7 +148,8 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \
"-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \ "-PWNH_DEBUG_BASE_URL=${WNH_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 lintStaging assembleStaging "-PWNH_TEST_BUILD_TYPE=staging" \
testStagingUnitTest lintStaging assembleStaging assembleStagingAndroidTest
FROM scratch AS staging-artifact FROM scratch AS staging-artifact
@ -157,6 +158,9 @@ USER 65532:65532
COPY --from=android-staging-sdk \ COPY --from=android-staging-sdk \
/workspace/android/app/build/outputs/apk/staging/app-staging.apk \ /workspace/android/app/build/outputs/apk/staging/app-staging.apk \
/who-need-help-staging.apk /who-need-help-staging.apk
COPY --from=android-staging-sdk \
/workspace/android/app/build/outputs/apk/androidTest/staging/app-staging-androidTest.apk \
/who-need-help-staging-androidTest.apk
COPY --from=android-staging-sdk \ COPY --from=android-staging-sdk \
/workspace/android/app/build/reports/lint-results-staging.html \ /workspace/android/app/build/reports/lint-results-staging.html \
/lint-results-staging.html /lint-results-staging.html

View File

@ -10,6 +10,8 @@ val debugBaseUrl = providers.gradleProperty("WNH_DEBUG_BASE_URL").orElse("")
val trackingMinTimeMs = providers.gradleProperty("WNH_TRACKING_MIN_TIME_MS").orElse("0") val trackingMinTimeMs = providers.gradleProperty("WNH_TRACKING_MIN_TIME_MS").orElse("0")
val trackingHttpTimeoutMs = val trackingHttpTimeoutMs =
providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0") providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0")
val instrumentationBuildType =
providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug")
fun manifestOrigin(value: String): URI? = fun manifestOrigin(value: String): URI? =
runCatching { URI(value) } runCatching { URI(value) }
@ -25,6 +27,7 @@ android {
namespace = "org.whoneedhelp.mobile" namespace = "org.whoneedhelp.mobile"
compileSdk = 37 compileSdk = 37
buildToolsVersion = "37.0.0" buildToolsVersion = "37.0.0"
testBuildType = instrumentationBuildType.get()
defaultConfig { defaultConfig {
applicationId = "org.whoneedhelp.mobile" applicationId = "org.whoneedhelp.mobile"

View File

@ -282,6 +282,37 @@ public final class AndroidClientInstrumentedTest {
} }
} }
@Test
public void test07MainPageFailureHasWorkingRetryAction() throws Exception {
server.disconnectNextGet("/retry-page");
try (ActivityScenario<MainActivity> scenario = launch("/retry-page")) {
assertTrue(
"Main-page failure did not expose the recovery message",
device.wait(
Until.hasObject(
By.text(context.getString(R.string.page_load_failed))
),
UI_TIMEOUT_MS
)
);
UiObject2 retry = device.wait(
Until.findObject(By.res("android", "button1")),
UI_TIMEOUT_MS
);
assertNotNull("Main-page failure did not expose a Retry action", retry);
assertTrue(
"The positive recovery action was not Retry",
context.getString(R.string.retry).equalsIgnoreCase(retry.getText())
);
retry.click();
onWebView()
.withElement(findElement(Locator.ID, "marker"))
.check(webMatches(getText(), containsString("loaded:/retry-page")));
}
}
private ActivityScenario<MainActivity> launch(String path) { private ActivityScenario<MainActivity> launch(String path) {
Intent intent = new Intent( Intent intent = new Intent(
Intent.ACTION_VIEW, Intent.ACTION_VIEW,

View File

@ -0,0 +1,198 @@
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.clearElement;
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 androidx.test.espresso.web.webdriver.DriverAtoms.webKeys;
import static org.hamcrest.Matchers.containsString;
import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.web.webdriver.Locator;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.UiDevice;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.concurrent.TimeUnit;
@RunWith(AndroidJUnit4.class)
public final class CrossClientStagingInstrumentedTest {
private static final long PAGE_TIMEOUT_SECONDS = 45;
private final Context context = ApplicationProvider.getApplicationContext();
private final UiDevice device =
UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
private final Bundle arguments =
InstrumentationRegistry.getArguments();
@Test
public void exchangeMessageAndTrackingWithBrowser() throws Exception {
String loginPath = requiredArgument("login_path");
String requestPath = requiredArgument("request_path");
String androidMessage = requiredArgument("android_message");
String browserReply = requiredArgument("browser_reply");
try (ActivityScenario<MainActivity> scenario = launch(loginPath)) {
waitForElementText(
"remember-login-button",
"Keep me logged in on this device"
);
click("remember-login-button");
waitForElementText(
"main-content",
"Help can be closer than you think."
);
}
try (ActivityScenario<MainActivity> scenario = launch(requestPath)) {
waitForSelector(".phx-connected");
waitForElementText("messages", "No messages yet.");
replaceText("message-body", androidMessage);
click("send-message-button");
waitForElementText("messages", androidMessage);
click("share-location-button");
waitForServiceState(true);
waitForElementText("messages", browserReply);
click("stop-location-button");
waitForServiceState(false);
}
}
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 String requiredArgument(String name) {
String value = arguments.getString(name);
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(
"Missing instrumentation argument: " + name
);
}
return value;
}
private static void click(String id) throws InterruptedException {
waitForElement(id);
onWebView()
.withElement(findElement(Locator.ID, id))
.perform(webClick());
}
private static void replaceText(String id, String value)
throws InterruptedException {
waitForElement(id);
onWebView()
.withElement(findElement(Locator.ID, id))
.perform(clearElement())
.perform(webKeys(value));
}
private static void waitForElement(String id) throws InterruptedException {
waitForElementText(id, "");
}
private static void waitForSelector(String selector)
throws InterruptedException {
long deadline =
System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS);
Throwable lastFailure = null;
while (System.nanoTime() < deadline) {
try {
onWebView()
.withElement(findElement(Locator.CSS_SELECTOR, selector))
.check(webMatches(getText(), containsString("")));
return;
} catch (AssertionError | RuntimeException failure) {
lastFailure = failure;
Thread.sleep(250);
}
}
AssertionError timeout = new AssertionError(
"WebView selector did not appear: " + selector
);
if (lastFailure != null) {
timeout.initCause(lastFailure);
}
throw timeout;
}
private static void waitForElementText(String id, String expected)
throws InterruptedException {
long deadline =
System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS);
Throwable lastFailure = null;
while (System.nanoTime() < deadline) {
try {
onWebView()
.withElement(findElement(Locator.ID, id))
.check(webMatches(getText(), containsString(expected)));
return;
} catch (AssertionError | RuntimeException failure) {
lastFailure = failure;
Thread.sleep(250);
}
}
AssertionError timeout = new AssertionError(
"WebView element #" + id + " did not contain: " + expected
);
if (lastFailure != null) {
timeout.initCause(lastFailure);
}
throw timeout;
}
private void waitForServiceState(boolean expected) throws Exception {
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(15);
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

@ -38,6 +38,7 @@ final class FixtureHttpServer implements Closeable {
Collections.synchronizedList(new ArrayList<>()); Collections.synchronizedList(new ArrayList<>());
private volatile boolean running = true; private volatile boolean running = true;
private volatile boolean disconnectStopRequest; private volatile boolean disconnectStopRequest;
private volatile String disconnectNextGetPath;
FixtureHttpServer() throws IOException { FixtureHttpServer() throws IOException {
URI base = URI.create(BuildConfig.BASE_URL); URI base = URI.create(BuildConfig.BASE_URL);
@ -64,6 +65,10 @@ final class FixtureHttpServer implements Closeable {
disconnectStopRequest = true; disconnectStopRequest = true;
} }
void disconnectNextGet(String path) {
disconnectNextGetPath = path;
}
RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit) RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit)
throws InterruptedException { throws InterruptedException {
return awaitRequestAfterCount(suffix, 0, timeout, unit); return awaitRequestAfterCount(suffix, 0, timeout, unit);
@ -179,6 +184,11 @@ final class FixtureHttpServer implements Closeable {
return; return;
} }
if ("GET".equals(request.method) && request.path.equals(disconnectNextGetPath)) {
disconnectNextGetPath = null;
return;
}
if ("GET".equals(request.method)) { if ("GET".equals(request.method)) {
writeResponse( writeResponse(
connection.getOutputStream(), connection.getOutputStream(),

View File

@ -0,0 +1,82 @@
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 org.hamcrest.Matchers.containsString;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.espresso.web.webdriver.Locator;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
import java.util.concurrent.TimeUnit;
@RunWith(AndroidJUnit4.class)
public final class PublicStagingInstrumentedTest {
private static final long PAGE_TIMEOUT_SECONDS = 45;
private final Context context = ApplicationProvider.getApplicationContext();
@Test
public void publicHomeAndSafetyDeepLinkRenderExpectedDom() throws Exception {
try (ActivityScenario<MainActivity> scenario = launch("/")) {
waitForElementText(
"main-content",
"Help can be closer than you think."
);
}
try (ActivityScenario<MainActivity> scenario = launch("/safety")) {
waitForElementText("main-content", "Safety rules");
}
}
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 static void waitForElementText(String id, String expected)
throws InterruptedException {
long deadline =
System.nanoTime() + TimeUnit.SECONDS.toNanos(PAGE_TIMEOUT_SECONDS);
Throwable lastFailure = null;
while (System.nanoTime() < deadline) {
try {
onWebView()
.withElement(findElement(Locator.ID, id))
.check(webMatches(getText(), containsString(expected)));
return;
} catch (AssertionError | RuntimeException failure) {
lastFailure = failure;
Thread.sleep(250);
}
}
AssertionError timeout = new AssertionError(
"WebView element #" + id + " did not contain: " + expected
);
if (lastFailure != null) {
timeout.initCause(lastFailure);
}
throw timeout;
}
}

View File

@ -2,6 +2,7 @@ package org.whoneedhelp.mobile;
import android.Manifest; import android.Manifest;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
@ -19,6 +20,7 @@ import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient; import android.webkit.WebChromeClient;
import android.webkit.WebResourceError; import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest; import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings; import android.webkit.WebSettings;
import android.webkit.WebView; import android.webkit.WebView;
import android.webkit.WebViewClient; import android.webkit.WebViewClient;
@ -41,6 +43,8 @@ public final class MainActivity extends ComponentActivity {
private ActivityResultLauncher<String[]> locationPermissionLauncher; private ActivityResultLauncher<String[]> locationPermissionLauncher;
private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher; private ActivityResultLauncher<String[]> nativeTrackingPermissionLauncher;
private PendingNativeTracking pendingNativeTracking; private PendingNativeTracking pendingNativeTracking;
private AlertDialog pageLoadErrorDialog;
private boolean mainFrameLoadFailed;
@Override @Override
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@ -184,6 +188,7 @@ public final class MainActivity extends ComponentActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
denyPendingLocation(); denyPendingLocation();
dismissPageLoadError();
if (webView != null) { if (webView != null) {
webView.stopLoading(); webView.stopLoading();
@ -359,6 +364,36 @@ public final class MainActivity extends ComponentActivity {
} }
} }
private void showPageLoadError(int messageResource) {
if (isFinishing() || isDestroyed()) {
return;
}
dismissPageLoadError();
pageLoadErrorDialog = new AlertDialog.Builder(this)
.setTitle(R.string.page_load_failed_title)
.setMessage(messageResource)
.setPositiveButton(R.string.retry, (dialog, which) -> {
if (webView != null) {
webView.reload();
}
})
.setNegativeButton(R.string.close, (dialog, which) -> finish())
.create();
pageLoadErrorDialog.setOnDismissListener(dialog -> pageLoadErrorDialog = null);
pageLoadErrorDialog.show();
}
private void dismissPageLoadError() {
AlertDialog dialog = pageLoadErrorDialog;
pageLoadErrorDialog = null;
if (dialog != null && dialog.isShowing()) {
dialog.setOnDismissListener(null);
dialog.dismiss();
}
}
private final class TrustedWebViewClient extends WebViewClient { private final class TrustedWebViewClient extends WebViewClient {
@Override @Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
@ -378,9 +413,9 @@ public final class MainActivity extends ComponentActivity {
SslErrorHandler handler, SslErrorHandler handler,
SslError error SslError error
) { ) {
mainFrameLoadFailed = true;
handler.cancel(); handler.cancel();
Toast.makeText(MainActivity.this, R.string.secure_connection_failed, Toast.LENGTH_LONG) showPageLoadError(R.string.secure_connection_failed);
.show();
} }
@Override @Override
@ -390,6 +425,8 @@ public final class MainActivity extends ComponentActivity {
WebResourceError error WebResourceError error
) { ) {
if (request.isForMainFrame()) { if (request.isForMainFrame()) {
mainFrameLoadFailed = true;
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.e( Log.e(
LOG_TAG, LOG_TAG,
@ -400,13 +437,27 @@ public final class MainActivity extends ComponentActivity {
); );
} }
Toast.makeText(MainActivity.this, R.string.page_load_failed, Toast.LENGTH_LONG) showPageLoadError(R.string.page_load_failed);
.show(); }
}
@Override
public void onReceivedHttpError(
WebView view,
WebResourceRequest request,
WebResourceResponse errorResponse
) {
if (request.isForMainFrame()) {
mainFrameLoadFailed = true;
showPageLoadError(R.string.page_load_failed);
} }
} }
@Override @Override
public void onPageStarted(WebView view, String url, Bitmap favicon) { public void onPageStarted(WebView view, String url, Bitmap favicon) {
mainFrameLoadFailed = false;
dismissPageLoadError();
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "Main-frame load started: path=" + safeLogPath(Uri.parse(url))); Log.d(LOG_TAG, "Main-frame load started: path=" + safeLogPath(Uri.parse(url)));
} }
@ -419,6 +470,10 @@ public final class MainActivity extends ComponentActivity {
@Override @Override
public void onPageFinished(WebView view, String url) { public void onPageFinished(WebView view, String url) {
if (!mainFrameLoadFailed) {
dismissPageLoadError();
}
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "Main-frame load finished: path=" + safeLogPath(Uri.parse(url))); Log.d(LOG_TAG, "Main-frame load finished: path=" + safeLogPath(Uri.parse(url)));
scheduleMapDiagnostics(view, Uri.parse(url)); scheduleMapDiagnostics(view, Uri.parse(url));

View File

@ -2,7 +2,10 @@
<resources> <resources>
<string name="app_name">Who Need Help</string> <string name="app_name">Who Need Help</string>
<string name="no_link_handler">No app can open this link.</string> <string name="no_link_handler">No app can open this link.</string>
<string name="page_load_failed_title">Page unavailable</string>
<string name="page_load_failed">Could not load Who Need Help. Check your connection and retry.</string> <string name="page_load_failed">Could not load Who Need Help. Check your connection and retry.</string>
<string name="retry">Retry</string>
<string name="close">Close</string>
<string name="secure_connection_failed">The secure connection was rejected.</string> <string name="secure_connection_failed">The secure connection was rejected.</string>
<string name="unsupported_link">This link type is not supported.</string> <string name="unsupported_link">This link type is not supported.</string>
<string name="tracking_channel_name">Active help location</string> <string name="tracking_channel_name">Active help location</string>

View File

@ -18,7 +18,7 @@ results from product limits and unknown production properties.
| Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. | | Reputation and anti-abuse | Implemented at MVP level | Handover codes, two-party completion, double-blind reviews, unique-counterpart ranking, optional movement/proximity evidence, reports, blocks, abuse signals, and moderator audit paths have automated tests. | The system is not bot-proof and does not claim identity verification. No punitive numeric policy is enabled without measured and approved thresholds. |
| Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; the final 172-test suite includes callback replay/state checks. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. | | Social profiles | Manual links implemented; optional GitHub verification implemented and automated-tested | Manual links cannot set verification fields. The optional GitHub flow uses state, PKCE, a user-bound one-time session, unique provider ownership, and an audit record; the final 172-test suite includes callback replay/state checks. The local protocol drill also performs real HTTP token/user exchanges without returning an access token to the application. | GitHub OAuth credentials are intentionally absent and are not required for registration or the help flow. The real external provider redirect/callback remains disabled and unverified; other providers remain manual/unverified. |
| Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. | | Voluntary thanks | Implemented as an external optional link | A helper can expose an optional link after completion; the UI states that the platform does not process the payment. | The platform does not provide payments, escrow, refunds, tax reporting, or payment guarantees. |
| Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Six lifecycle, permission, deep-link, foreground tracking, network-failure, notification-Stop, and Activity-destruction tests passed on each of API 30, 34, and 37. The API 37 staging smoke loaded the public home and Safety routes over HTTPS with zero observed load/TLS errors. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. | | Android client | Local and public-staging clients implemented and emulator-verified | The native packages `org.whoneedhelp.mobile.debug` and `org.whoneedhelp.mobile.staging` launch the same authenticated LiveView app. Seven lifecycle, permission, deep-link, foreground tracking, recoverable main-page failure, notification-Stop, and Activity-destruction tests passed on each of API 30, 34, and 37. The API 37 staging smoke asserted the public home and Safety DOM over HTTPS. A run-scoped Android/browser staging test passed login, private chat in both directions, foreground tracking, live marker appearance/removal, and exact cleanup. | Production signing, Play Store publication, verified Android App Links, unattended/background-permission tracking, and iOS are not implemented. |
| Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. | | Multiple web/worker instances | Implemented and locally failure/rollout-verified | The final isolated Compose drill passed BEAM crashes and sequential replacement with 3 web/2 worker replicas: all five nodes joined, PubSub passed, and 744/744 readiness requests succeeded. The project-owned kind cluster replaced all 2 web/2 worker pod UIDs under `maxUnavailable=0`; all four replacement pods joined, PubSub passed, and 363/363 samples ultimately succeeded. | Local PostGIS is a single instance. Production database HA, backups, and recovery are operator work and are not claimed complete. |
| Local observability | Implemented and protocol-verified | Pinned Prometheus scraped all 3 direct load web targets with a file Bearer credential; Grafana provisioned a healthy datasource and four-panel dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. | | Local observability | Implemented and protocol-verified | Pinned Prometheus scraped all 3 direct load web targets with a file Bearer credential; Grafana provisioned a healthy datasource and four-panel dashboard; Alertmanager delivered firing and resolved webhooks for an induced scoped replica stop. | Local delivery does not establish production retention, notification-provider reliability, on-call policy, or measured alert thresholds. |
| Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. | | Encrypted local backup | Implemented and failure-verified | Pinned Restic streamed PostgreSQL custom format into pinned local MinIO with no host plaintext dump, passed full-data checking and a fresh-database restore, rejected a corrupted repository, and published no snapshot for an interrupted upload. The one-run MinIO project and volume were removed after retaining the non-secret evidence. | The drill proves the local mechanism, not off-site durability, database HA, or a production RPO/RTO/retention policy. |
@ -59,16 +59,19 @@ results from product limits and unknown production properties.
- `mix format --check-formatted`: passed in the final run. - `mix format --check-formatted`: passed in the final run.
- Android local Docker build targets `testDebugUnitTest`, `lintDebug`, - Android local Docker build targets `testDebugUnitTest`, `lintDebug`,
`assembleDebug`, and `assembleDebugAndroidTest` passed. The isolated runners `assembleDebug`, and `assembleDebugAndroidTest` passed. The isolated runners
then passed 6/6 instrumentation tests on API 30, API 34, and API 37. The then passed 7/7 instrumentation tests on API 30, API 34, and API 37. The
sixth test verifies that the foreground service survives Home plus Activity sixth test verifies that the foreground service survives Home plus Activity
destruction and remains user-stoppable. The public-staging target passed destruction and remains user-stoppable. The seventh forces the first
unit tests, `lintStaging`, and `assembleStaging`; the API 37 staging smoke main-frame request to disconnect, checks the native recovery dialog, presses
loaded `/` and `/safety` from the temporary HTTPS origin with no observed Retry, and asserts the subsequently rendered WebView DOM. The
load/TLS errors. Evidence is retained at public-staging target passed unit tests, `lintStaging`, `assembleStaging`,
`output/android-instrumentation/api30/20260719162757-2877617`, and `assembleStagingAndroidTest`; the API 37 staging smoke asserted the
`output/android-instrumentation/api34/20260719163525-3053597`, rendered `/` and `/safety` DOM from the temporary HTTPS origin with no
`output/android-instrumentation/api37-0/20260719164309-3237374`, and observed load/TLS errors. Evidence is retained at
`output/android-staging-smoke/20260719171121-3924350`. `output/android-instrumentation/api30/20260719222936-2642890`,
`output/android-instrumentation/api34/20260719223131-2695250`,
`output/android-instrumentation/api37-0/20260719224048-2695249`, and
`output/android-staging-smoke/20260719230137-3479426`.
- Release Android guard: a staging/release build with a non-HTTPS - Release Android guard: a staging/release build with a non-HTTPS
`WNH_BASE_URL` failed at its dedicated preflight; the successful staging `WNH_BASE_URL` failed at its dedicated preflight; the successful staging
build used the explicit temporary HTTPS origin. build used the explicit temporary HTTPS origin.
@ -411,6 +414,17 @@ public-staging Android app as the helper. The observed behavior was:
exact tracking session, left zero active sessions, and deleted its raw current exact tracking session, left zero active sessions, and deleted its raw current
position. position.
The final reproducible cross-client runner performed the same boundary through
the staging instrumentation APK and containerized Chromium. Android consumed a
run-scoped one-time login token, chat messages crossed in both directions
without reload, Android foreground tracking produced a live marker in the
browser, and stopping it removed the marker. The fixture verifier found exactly
the two expected messages, one ended tracking session, and no raw current
position. Cleanup removed only the UUIDs recorded in the run manifest; the
before/after count diff across 19 application tables was empty. Browser,
instrumentation, fixture verification, and cleanup all passed. Evidence is
`output/android-browser-staging-e2e/20260719232413-3987628`.
The browser scenario exposed one UI defect: movement evidence was persisted and The browser scenario exposed one UI defect: movement evidence was persisted and
the marker updated, but the evidence badge remained stale until reload. The the marker updated, but the evidence badge remained stale until reload. The
tracking PubSub event now carries the already-derived movement/proximity tracking PubSub event now carries the already-derived movement/proximity
@ -475,12 +489,12 @@ after this section.
medicine handover, blind reviews, Activity privacy/moderation, localization, medicine handover, blind reviews, Activity privacy/moderation, localization,
reconnect, and active chat/tracking recovery after the serving BEAM node reconnect, and active chat/tracking recovery after the serving BEAM node
stops. Evidence is `output/e2e/20260719162432-2790071`. stops. Evidence is `output/e2e/20260719162432-2790071`.
- Android debug and staging unit/lint/build gates passed. Six instrumentation - Android debug and staging unit/lint/build gates passed. Seven instrumentation
tests passed independently on API 30, 34, and 37, including the foreground tests passed independently on API 30, 34, and 37, including foreground
tracking service surviving Home plus Activity destruction. The separate API tracking surviving Home plus Activity destruction and native Retry recovery
37 public-staging smoke loaded home and Safety over the exact temporary HTTPS after a forced main-frame disconnect. The separate API 37 public-staging
origin, matched no external-origin app filter, and observed zero load/TLS smoke asserted the home and Safety DOM over the exact temporary HTTPS origin,
errors. matched no external-origin app filter, and observed zero load/TLS errors.
- A real public Chromium two-user scenario registered isolated requester/helper - A real public Chromium two-user scenario registered isolated requester/helper
fixtures, completed medicine discovery, acceptance, realtime chat, consent fixtures, completed medicine discovery, acceptance, realtime chat, consent
tracking, handover, both confirmations, and blind reviews. It passed 1/1 in tracking, handover, both confirmations, and blind reviews. It passed 1/1 in

View File

@ -5,10 +5,11 @@ WORKDIR /work
COPY --chown=pwuser:pwuser package.json package-lock.json ./ COPY --chown=pwuser:pwuser package.json package-lock.json ./
RUN npm ci --ignore-scripts RUN npm ci --ignore-scripts
COPY --chown=pwuser:pwuser playwright.config.ts playwright.bootstrap.config.ts playwright.pwa.config.ts ./ COPY --chown=pwuser:pwuser playwright.config.ts playwright.bootstrap.config.ts playwright.pwa.config.ts playwright.cross-client.config.ts ./
COPY --chown=pwuser:pwuser tests tests COPY --chown=pwuser:pwuser tests tests
COPY --chown=pwuser:pwuser setup setup COPY --chown=pwuser:pwuser setup setup
COPY --chown=pwuser:pwuser pwa pwa COPY --chown=pwuser:pwuser pwa pwa
COPY --chown=pwuser:pwuser cross-client cross-client
USER pwuser USER pwuser

View File

@ -0,0 +1,52 @@
import { expect, test } from "@playwright/test";
import {
captureBrowserFailures,
gotoLiveView,
loginWithPassword,
markerKinds,
} from "../tests/helpers";
const phase = requiredEnv("CROSS_CLIENT_PHASE");
const requesterEmail = requiredEnv("CROSS_CLIENT_REQUESTER_EMAIL");
const fixturePassword = requiredEnv("CROSS_CLIENT_PASSWORD");
const requestPath = requiredEnv("CROSS_CLIENT_REQUEST_PATH");
const androidMessage = requiredEnv("CROSS_CLIENT_ANDROID_MESSAGE");
const browserReply = requiredEnv("CROSS_CLIENT_BROWSER_REPLY");
test("browser and Android share chat and live tracking state", async ({ browser }) => {
const requester = await loginWithPassword(browser, requesterEmail, fixturePassword);
const assertClean = captureBrowserFailures(requester.page);
await gotoLiveView(requester.page, requestPath);
await expect(
requester.page.getByRole("heading", { name: "Private match chat" }),
).toBeVisible();
await expect(requester.page.getByText(androidMessage, { exact: true })).toBeVisible();
if (phase === "observe_android") {
await expect.poll(() => markerKinds(requester.page)).toContain("Shared live location");
await requester.page
.getByPlaceholder("Write a safe coordination message…")
.fill(browserReply);
await requester.page.getByRole("button", { name: "Send", exact: true }).click();
await expect(requester.page.getByText(browserReply, { exact: true })).toBeVisible();
} else if (phase === "observe_stopped") {
await expect(requester.page.getByText(browserReply, { exact: true })).toBeVisible();
await expect.poll(() => markerKinds(requester.page)).not.toContain("Shared live location");
} else {
throw new Error(`Unsupported CROSS_CLIENT_PHASE: ${phase}`);
}
assertClean();
await requester.context.close();
});
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}

View File

@ -0,0 +1,37 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = process.env.BASE_URL;
if (!baseURL) {
throw new Error("BASE_URL is required");
}
export default defineConfig({
testDir: "./cross-client",
outputDir: "./output/test-results",
fullyParallel: false,
workers: 1,
forbidOnly: true,
retries: 0,
timeout: 60_000,
expect: {
timeout: 12_000,
},
reporter: [
["line"],
["html", { outputFolder: "./output/html-report", open: "never" }],
["json", { outputFile: "./output/results.json" }],
],
use: {
...devices["Desktop Chrome"],
baseURL,
ignoreHTTPSErrors: false,
serviceWorkers: "block",
actionTimeout: 10_000,
navigationTimeout: 20_000,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [{ name: "chromium" }],
});

View File

@ -0,0 +1,474 @@
defmodule Mix.Tasks.Wnh.StagingAndroidE2e do
use Mix.Task
import Ecto.Query
alias Oban.Job
alias WhoNeedHelp.Accounts.{User, UserToken}
alias WhoNeedHelp.Catalog.Category
alias WhoNeedHelp.Help.{Assignment, HelpRequest}
alias WhoNeedHelp.Messaging.Message
alias WhoNeedHelp.Repo
alias WhoNeedHelp.Tracking.{Position, TrackingSession}
alias WhoNeedHelp.Trust.{AuditEvent, RateLimitBucket}
@shortdoc "Prepares or removes an exact Android/public-staging cross-client fixture"
@confirmation "public-staging-android-e2e"
@roles ~w(requester helper)
@impl Mix.Task
def run([action]) when action in ["prepare", "verify", "cleanup"] do
Mix.Task.run("app.start")
context = verified_context()
case action do
"prepare" -> prepare(context)
"verify" -> verify(context)
"cleanup" -> cleanup(context)
end
end
def run(_args) do
Mix.raise("usage: mix wnh.staging_android_e2e prepare|verify|cleanup")
end
defp verified_context do
run_id = required_env!("WNH_ANDROID_E2E_RUN_ID")
expected_database = required_env!("WNH_ANDROID_E2E_EXPECTED_DATABASE")
manifest_path = required_env!("WNH_ANDROID_E2E_MANIFEST_PATH") |> Path.expand()
unless Regex.match?(~r/^[a-z0-9-]+$/, run_id) do
Mix.raise("WNH_ANDROID_E2E_RUN_ID may contain only lowercase letters, numbers, and dash")
end
unless System.get_env("WNH_ANDROID_E2E_CONFIRM") == @confirmation do
Mix.raise("WNH_ANDROID_E2E_CONFIRM must equal #{@confirmation}")
end
unless String.starts_with?(manifest_path, "/output/") do
Mix.raise("WNH_ANDROID_E2E_MANIFEST_PATH must resolve below /output")
end
%Postgrex.Result{rows: [[actual_database]]} =
Repo.query!("SELECT current_database()", [], log: false)
unless actual_database == expected_database do
Mix.raise(
"refusing Android staging E2E mutation: expected database " <>
"#{inspect(expected_database)}, observed #{inspect(actual_database)}"
)
end
%{
run_id: run_id,
database: actual_database,
manifest_path: manifest_path,
emails: Map.new(@roles, &{&1, "#{prefix(run_id)}#{&1}@example.invalid"})
}
end
defp prepare(context) do
password = required_env!("WNH_ANDROID_E2E_PASSWORD")
unless byte_size(password) in 12..72 do
Mix.raise("WNH_ANDROID_E2E_PASSWORD must contain between 12 and 72 bytes")
end
assert_prefix_unused!(context)
password_hash = Bcrypt.hash_pwd_salt(password)
now = DateTime.utc_now(:second)
category = Repo.get_by!(Category, slug: "medicine-pickup", active: true)
{:ok, fixture} =
Repo.transaction(fn ->
users =
Map.new(@roles, fn role ->
user =
insert_user!(
context.emails[role],
"Android E2E #{String.capitalize(role)}",
password_hash,
now
)
{role, user}
end)
request =
%HelpRequest{requester_id: users["requester"].id}
|> HelpRequest.create_changeset(%{
"title" => "Android cross-client medicine pickup #{context.run_id}",
"description" =>
"Run-scoped medicine request for Android and browser interoperability.",
"structured_data" => %{"pickup_status" => "reserved"},
"location_label" => "Android E2E central district",
"latitude" => "50.4501",
"longitude" => "30.5234",
"urgency" => "now",
"location_visibility" => "exact_for_active_match",
"expires_at" => DateTime.add(now, 3 * 60 * 60, :second),
"category_id" => category.id
})
|> Ecto.Changeset.put_change(:status, :matched)
|> Repo.insert!()
assignment =
%Assignment{}
|> Assignment.changeset(%{
request_id: request.id,
helper_id: users["helper"].id,
status: :accepted,
accepted_at: now,
handover_code_hash: :crypto.hash(:sha256, context.run_id)
})
|> Repo.insert!()
{helper_login_token, helper_login_record} =
UserToken.build_email_token(users["helper"], "login")
Repo.insert!(helper_login_record)
%{
users: users,
request: request,
assignment: assignment,
helper_login_token: helper_login_token
}
end)
manifest = %{
"schema_version" => 1,
"run_id" => context.run_id,
"database" => context.database,
"users" =>
Map.new(fixture.users, fn {role, user} ->
{role, %{"id" => user.id, "email" => user.email}}
end),
"request" => %{
"id" => fixture.request.id,
"path" => "/requests/#{fixture.request.id}"
},
"assignment" => %{"id" => fixture.assignment.id},
"helper_login_path" => "/users/log-in/#{fixture.helper_login_token}"
}
context.manifest_path |> Path.dirname() |> File.mkdir_p!()
File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
File.chmod!(context.manifest_path, 0o600)
Mix.shell().info("prepared exact Android staging E2E fixture for #{context.run_id}")
end
defp cleanup(context) do
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
validate_manifest!(context, manifest)
assert_only_allowed_prefix_users!(context)
users =
User
|> where([user], user.email in ^Map.values(context.emails))
|> Repo.all()
validate_users!(manifest, users)
user_ids = Enum.map(users, & &1.id)
request_id = manifest["request"]["id"]
assignment_id = manifest["assignment"]["id"]
requester_id = manifest["users"]["requester"]["id"]
helper_id = manifest["users"]["helper"]["id"]
validate_fixture_links!(request_id, assignment_id, requester_id, helper_id)
message_ids =
Message
|> where([message], message.assignment_id == ^assignment_id)
|> select([message], message.id)
|> Repo.all()
tracking_session_ids =
TrackingSession
|> where([session], session.assignment_id == ^assignment_id)
|> select([session], session.id)
|> Repo.all()
validate_interactions!(assignment_id, user_ids)
job_ids = fixture_push_job_ids(user_ids, assignment_id)
scope_hashes = Enum.map(user_ids, &:crypto.hash(:sha256, &1))
{:ok, deleted} =
Repo.transaction(fn ->
%{
push_jobs: delete_ids(Job, job_ids),
rate_limit_buckets:
RateLimitBucket
|> where([bucket], bucket.scope_hash in ^scope_hashes)
|> delete_count(),
audit_events:
AuditEvent
|> where([event], event.actor_id in ^user_ids)
|> delete_count(),
positions:
Position
|> where([position], position.tracking_session_id in ^tracking_session_ids)
|> delete_count(),
tracking_sessions: delete_ids(TrackingSession, tracking_session_ids),
messages: delete_ids(Message, message_ids),
assignment: delete_ids(Assignment, [assignment_id]),
request: delete_ids(HelpRequest, [request_id]),
users:
User
|> where([user], user.id in ^user_ids)
|> delete_count()
}
end)
unless deleted.users == length(@roles) and deleted.assignment == 1 and deleted.request == 1 do
Mix.raise("Android staging E2E cleanup did not remove the exact fixture")
end
assert_prefix_unused!(context)
Mix.shell().info("removed exact Android staging E2E fixture: #{inspect(deleted)}")
end
defp verify(context) do
manifest = context.manifest_path |> File.read!() |> Jason.decode!()
validate_manifest!(context, manifest)
request_id = manifest["request"]["id"]
assignment_id = manifest["assignment"]["id"]
requester_id = manifest["users"]["requester"]["id"]
helper_id = manifest["users"]["helper"]["id"]
android_message = required_env!("WNH_ANDROID_E2E_ANDROID_MESSAGE")
browser_reply = required_env!("WNH_ANDROID_E2E_BROWSER_REPLY")
validate_fixture_links!(request_id, assignment_id, requester_id, helper_id)
validate_interactions!(assignment_id, [requester_id, helper_id])
messages =
Message
|> where([message], message.assignment_id == ^assignment_id)
|> select([message], {message.sender_id, message.body})
|> Repo.all()
|> Enum.sort()
expected_messages =
[{helper_id, android_message}, {requester_id, browser_reply}]
|> Enum.sort()
unless messages == expected_messages do
Mix.raise("expected the exact Android/browser messages, observed #{inspect(messages)}")
end
sessions =
TrackingSession
|> where(
[session],
session.assignment_id == ^assignment_id and session.user_id == ^helper_id
)
|> Repo.all()
valid_session? =
match?(
[
%TrackingSession{
active: false,
ended_at: %DateTime{},
sample_count: sample_count
}
]
when sample_count >= 1,
sessions
)
position_count =
Position
|> join(:inner, [position], session in TrackingSession,
on: session.id == position.tracking_session_id
)
|> where([_position, session], session.assignment_id == ^assignment_id)
|> Repo.aggregate(:count)
unless valid_session? and position_count == 0 do
Mix.raise(
"expected one stopped helper tracking session with samples and no retained position"
)
end
verification_path =
context.manifest_path
|> Path.dirname()
|> Path.join("fixture-verification.json")
File.write!(
verification_path,
Jason.encode_to_iodata!(
%{
"run_id" => context.run_id,
"request_id" => request_id,
"assignment_id" => assignment_id,
"message_count" => length(messages),
"tracking_session_count" => length(sessions),
"tracking_sample_count" => hd(sessions).sample_count,
"active_tracking_sessions" => 0,
"retained_positions" => position_count
},
pretty: true
)
)
File.chmod!(verification_path, 0o600)
Mix.shell().info("verified exact Android/browser staging E2E fixture")
end
defp validate_manifest!(context, manifest) do
valid_users? =
is_map(manifest["users"]) and
Enum.all?(@roles, fn role ->
case manifest["users"][role] do
%{"id" => id, "email" => email} ->
uuid?(id) and email == context.emails[role]
_other ->
false
end
end)
valid_request? =
match?(
%{"id" => id, "path" => path} when is_binary(id) and is_binary(path),
manifest["request"]
) and uuid?(manifest["request"]["id"]) and
manifest["request"]["path"] == "/requests/#{manifest["request"]["id"]}"
valid_assignment? =
match?(%{"id" => id} when is_binary(id), manifest["assignment"]) and
uuid?(manifest["assignment"]["id"])
unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
manifest["database"] == context.database and valid_users? and valid_request? and
valid_assignment? and is_binary(manifest["helper_login_path"]) and
String.starts_with?(manifest["helper_login_path"], "/users/log-in/") do
Mix.raise("Android staging E2E manifest does not match the requested run and database")
end
end
defp validate_users!(manifest, users) do
observed = Map.new(users, &{&1.email, &1.id})
unless length(users) == length(@roles) and
Enum.all?(@roles, fn role ->
expected = manifest["users"][role]
observed[expected["email"]] == expected["id"]
end) do
Mix.raise("Android staging E2E user ownership does not match the manifest")
end
end
defp validate_fixture_links!(request_id, assignment_id, requester_id, helper_id) do
request = Repo.get(HelpRequest, request_id)
assignment = Repo.get(Assignment, assignment_id)
valid? =
match?(%HelpRequest{}, request) and match?(%Assignment{}, assignment) and
request.requester_id == requester_id and assignment.request_id == request_id and
assignment.helper_id == helper_id and helper_id != requester_id
unless valid?, do: Mix.raise("Android staging E2E fixture links no longer match the manifest")
end
defp validate_interactions!(assignment_id, user_ids) do
unexpected_message? =
Repo.exists?(
from(message in Message,
where:
(message.assignment_id == ^assignment_id and message.sender_id not in ^user_ids) or
(message.sender_id in ^user_ids and message.assignment_id != ^assignment_id)
)
)
unexpected_tracking? =
Repo.exists?(
from(session in TrackingSession,
where:
(session.assignment_id == ^assignment_id and session.user_id not in ^user_ids) or
(session.user_id in ^user_ids and session.assignment_id != ^assignment_id)
)
)
if unexpected_message? or unexpected_tracking? do
Mix.raise("Android staging fixture users interacted with non-fixture data")
end
end
defp fixture_push_job_ids(user_ids, assignment_id) do
recipients = Enum.map(user_ids, &"user:#{&1}")
Job
|> where([job], job.worker == "WhoNeedHelp.Push.DeliveryWorker")
|> where(
[job],
fragment("?->>'recipient' = ANY(?)", job.args, ^recipients) or
fragment("?->>'assignment_id' = ?", job.args, ^assignment_id)
)
|> select([job], job.id)
|> Repo.all()
end
defp assert_prefix_unused!(context) do
if Repo.exists?(from(user in User, where: like(user.email, ^"#{prefix(context.run_id)}%"))) do
Mix.raise("Android staging E2E users still exist for #{inspect(context.run_id)}")
end
end
defp assert_only_allowed_prefix_users!(context) do
allowed = Map.values(context.emails)
unexpected =
User
|> where([user], like(user.email, ^"#{prefix(context.run_id)}%"))
|> where([user], user.email not in ^allowed)
|> select([user], user.email)
|> Repo.all()
if unexpected != [] do
Mix.raise("unexpected users share the Android staging run prefix: #{inspect(unexpected)}")
end
end
defp delete_ids(_schema, []), do: 0
defp delete_ids(schema, ids) do
schema
|> where([row], row.id in ^ids)
|> delete_count()
end
defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)
defp insert_user!(email, display_name, password_hash, now) do
%User{}
|> User.registration_changeset(%{
"email" => email,
"display_name" => display_name,
"locale" => "en",
"terms_accepted" => true
})
|> Ecto.Changeset.put_change(:hashed_password, password_hash)
|> Ecto.Changeset.put_change(:confirmed_at, now)
|> Repo.insert!()
end
defp prefix(run_id), do: "wnh-android-e2e-#{run_id}-"
defp required_env!(name) do
case System.get_env(name) do
value when is_binary(value) and value != "" -> value
_ -> Mix.raise("#{name} is required")
end
end
defp uuid?(value) when is_binary(value), do: match?({:ok, _}, Ecto.UUID.cast(value))
defp uuid?(_value), do: false
end

View File

@ -46,6 +46,7 @@
</.button> </.button>
<% else %> <% else %>
<.button <.button
id="remember-login-button"
name={@form[:remember_me].name} name={@form[:remember_me].name}
value="true" value="true"
phx-disable-with={gettext("Logging in...")} phx-disable-with={gettext("Logging in...")}

View File

@ -712,11 +712,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
class="mt-4 flex gap-2" class="mt-4 flex gap-2"
> >
<.input <.input
id="message-body"
field={@message_form[:body]} field={@message_form[:body]}
placeholder={gettext("Write a safe coordination message…")} placeholder={gettext("Write a safe coordination message…")}
class="grow" class="grow"
/> />
<.button class="btn btn-primary self-end">{gettext("Send")}</.button> <.button id="send-message-button" class="btn btn-primary self-end">
{gettext("Send")}
</.button>
</.form> </.form>
</section> </section>
@ -943,6 +946,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div> <div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
<button <button
:if={!@tracking_active} :if={!@tracking_active}
id="share-location-button"
phx-click="start-tracking" phx-click="start-tracking"
class="btn btn-outline btn-sm mt-4 w-full" class="btn btn-outline btn-sm mt-4 w-full"
> >
@ -950,6 +954,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
</button> </button>
<button <button
:if={@tracking_active} :if={@tracking_active}
id="stop-location-button"
phx-click="stop-tracking" phx-click="stop-tracking"
class="btn btn-error btn-sm mt-4 w-full" class="btn btn-error btn-sm mt-4 w-full"
> >

View File

@ -0,0 +1,407 @@
#!/bin/bash
set -euo pipefail
umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
ENV_FILE="$ROOT/.env"
APK="$ROOT/android/dist-staging/who-need-help-staging.apk"
TEST_APK="$ROOT/android/dist-staging/who-need-help-staging-androidTest.apk"
run_id="$(date -u +%Y%m%d%H%M%S)-$$"
output_dir="$ROOT/output/android-browser-staging-e2e/$run_id"
tools_image="who-need-help:android-e2e-tools-$run_id"
browser_image="who-need-help-e2e-tests:android-$run_id"
android_image="who-need-help-android:cross-client-$run_id"
container="who-need-help-android-cross-client-$run_id"
package=org.whoneedhelp.mobile.staging
service_class=org.whoneedhelp.mobile.TrackingService
fixture_password="$(openssl rand -hex 24)"
android_message="android-$run_id"
browser_reply="browser-$run_id"
prepared=0
android_runner_pid=
android_runner_output=
if [[ ! -e /dev/kvm ]]; then
echo "/dev/kvm is required for the containerized Android emulator." >&2
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
echo "Missing $ENV_FILE." >&2
exit 1
fi
if [[ ! -s "$APK" ]]; then
echo "Missing staging APK: $APK. Run scripts/android-staging-build.sh first." >&2
exit 1
fi
if [[ ! -s "$TEST_APK" ]]; then
echo "Missing staging test APK: $TEST_APK. Run scripts/android-staging-build.sh first." >&2
exit 1
fi
set -a
# shellcheck source=/dev/null
. "$ENV_FILE"
set +a
: "${POSTGRES_DB:?POSTGRES_DB is missing from .env}"
: "${WNH_BASE_URL:?WNH_BASE_URL is missing from .env}"
: "${WNH_TRACKING_MIN_TIME_MS:?WNH_TRACKING_MIN_TIME_MS is missing from .env}"
: "${WNH_TRACKING_HTTP_TIMEOUT_MS:?WNH_TRACKING_HTTP_TIMEOUT_MS is missing from .env}"
case "$WNH_BASE_URL" in
https://*/* | https://*) ;;
*)
echo "WNH_BASE_URL must be an HTTPS origin." >&2
exit 1
;;
esac
origin_without_scheme=${WNH_BASE_URL#https://}
case "$origin_without_scheme" in
"" | */* | *\?* | *\#* | *@*)
echo "WNH_BASE_URL must be a credential-free HTTPS origin without a path." >&2
exit 1
;;
esac
expected_host=${origin_without_scheme%%:*}
mkdir -p "$output_dir"
chmod 700 "$ROOT/output" "$ROOT/output/android-browser-staging-e2e" "$output_dir"
db_container=$(docker compose ps -q db)
if [[ -z "$db_container" ]]; then
echo "The ordinary Compose database container is not running." >&2
exit 1
fi
internal_network_id=$(
docker inspect "$db_container" |
jq -r '.[0].NetworkSettings.Networks | to_entries[] | select(.key | endswith("_internal")) | .value.NetworkID' |
head -n 1
)
if [[ -z "$internal_network_id" ]]; then
echo "Could not identify the ordinary Compose internal network." >&2
exit 1
fi
snapshot_database() {
local destination=$1
docker compose exec -T db sh -c \
'psql --no-psqlrc --set ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB"' \
>"$destination" <<'SQL'
BEGIN READ ONLY;
SELECT 'users' AS table_name, count(*) AS row_count FROM users
UNION ALL SELECT 'users_tokens', count(*) FROM users_tokens
UNION ALL SELECT 'help_requests', count(*) FROM help_requests
UNION ALL SELECT 'messages', count(*) FROM messages
UNION ALL SELECT 'help_assignments', count(*) FROM help_assignments
UNION ALL SELECT 'reports', count(*) FROM reports
UNION ALL SELECT 'reviews', count(*) FROM reviews
UNION ALL SELECT 'audit_events', count(*) FROM audit_events
UNION ALL SELECT 'abuse_signals', count(*) FROM abuse_signals
UNION ALL SELECT 'tracking_sessions', count(*) FROM tracking_sessions
UNION ALL SELECT 'tracking_positions', count(*) FROM tracking_positions
UNION ALL SELECT 'activities', count(*) FROM activities
UNION ALL SELECT 'activity_participants', count(*) FROM activity_participants
UNION ALL SELECT 'activity_messages', count(*) FROM activity_messages
UNION ALL SELECT 'category_proposals', count(*) FROM category_proposals
UNION ALL SELECT 'category_votes', count(*) FROM category_votes
UNION ALL SELECT 'blocks', count(*) FROM blocks
UNION ALL SELECT 'social_identities', count(*) FROM social_identities
UNION ALL SELECT 'rate_limit_buckets', count(*) FROM rate_limit_buckets
ORDER BY table_name;
COMMIT;
SQL
}
run_fixture_tool() {
local action=$1
docker run --rm \
--network "$internal_network_id" \
--env-file "$ENV_FILE" \
--env APP_ROLE=migrate \
--env "WNH_ANDROID_E2E_EXPECTED_DATABASE=$POSTGRES_DB" \
--env WNH_ANDROID_E2E_CONFIRM=public-staging-android-e2e \
--env "WNH_ANDROID_E2E_RUN_ID=$run_id" \
--env "WNH_ANDROID_E2E_PASSWORD=$fixture_password" \
--env "WNH_ANDROID_E2E_ANDROID_MESSAGE=$android_message" \
--env "WNH_ANDROID_E2E_BROWSER_REPLY=$browser_reply" \
--env WNH_ANDROID_E2E_MANIFEST_PATH=/output/fixture.json \
--volume "$output_dir:/output" \
"$tools_image" \
mix wnh.staging_android_e2e "$action"
}
adb() {
docker exec "$container" adb "$@"
}
run_browser_phase() {
local phase=$1
local phase_output="$output_dir/browser-$phase"
mkdir -p "$phase_output"
chmod 700 "$phase_output"
docker run --rm \
--network host \
--user "$(id -u):$(id -g)" \
--env "BASE_URL=$WNH_BASE_URL" \
--env MAILPIT_URL=http://127.0.0.1:8027 \
--env "CROSS_CLIENT_PHASE=$phase" \
--env "CROSS_CLIENT_REQUESTER_EMAIL=$requester_email" \
--env "CROSS_CLIENT_PASSWORD=$fixture_password" \
--env "CROSS_CLIENT_REQUEST_PATH=$request_path" \
--env "CROSS_CLIENT_ANDROID_MESSAGE=$android_message" \
--env "CROSS_CLIENT_BROWSER_REPLY=$browser_reply" \
--env HOME=/tmp \
--volume "$phase_output:/work/output" \
"$browser_image" \
npx playwright test \
--config=playwright.cross-client.config.ts \
--project=chromium
}
start_android_phase() {
local method=$1
android_runner_output="$output_dir/android-$method.txt"
adb shell am instrument -w -r \
-e class \
"org.whoneedhelp.mobile.CrossClientStagingInstrumentedTest#$method" \
-e login_path "$helper_login_path" \
-e request_path "$request_path" \
-e android_message "$android_message" \
-e browser_reply "$browser_reply" \
org.whoneedhelp.mobile.staging.test/androidx.test.runner.AndroidJUnitRunner \
>"$android_runner_output" 2>&1 &
android_runner_pid=$!
}
wait_android_phase() {
local status
set +e
wait "$android_runner_pid"
status=$?
set -e
android_runner_pid=
if [[ "$status" -ne 0 ]] ||
! grep -Eq '^OK \(1 test\)$' "$android_runner_output" ||
grep -Eq 'FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed' \
"$android_runner_output"; then
cat "$android_runner_output" >&2
echo "Android cross-client instrumentation phase failed." >&2
return 1
fi
}
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if docker inspect "$container" >/dev/null 2>&1; then
adb logcat -d >"$output_dir/logcat.txt" 2>&1 || true
adb shell dumpsys notification --noredact >"$output_dir/notifications.txt" 2>&1 || true
adb shell dumpsys activity services "$package" >"$output_dir/services.txt" 2>&1 || true
adb exec-out screencap -p >"$output_dir/final.png" 2>/dev/null || true
docker logs "$container" >"$output_dir/emulator.log" 2>&1 || true
fi
if [[ "$prepared" -eq 1 ]]; then
if ! run_fixture_tool cleanup >"$output_dir/fixture-cleanup.log" 2>&1; then
echo "Exact Android/browser staging cleanup failed; inspect $output_dir." >&2
status=1
else
snapshot_database "$output_dir/database-after.txt"
if ! diff -u \
"$output_dir/database-before.txt" \
"$output_dir/database-after.txt" \
>"$output_dir/database-cleanup.diff"; then
echo "Android/browser cleanup did not restore application table counts." >&2
status=1
fi
fi
fi
docker rm -f "$container" >/dev/null 2>&1 || true
docker image rm "$android_image" "$browser_image" "$tools_image" >/dev/null 2>&1 || true
{
printf 'container_absent='
if docker inspect "$container" >/dev/null 2>&1; then
printf 'false\n'
else
printf 'true\n'
fi
printf 'fixture_prefix_absent='
if [[ -f "$output_dir/database-cleanup.diff" ]] &&
[[ ! -s "$output_dir/database-cleanup.diff" ]]; then
printf 'true\n'
else
printf 'unknown\n'
fi
} >"$output_dir/cleanup.txt"
unset fixture_password
exit "$status"
}
trap cleanup EXIT HUP INT TERM
curl --fail --silent --show-error "$WNH_BASE_URL/healthz/ready" \
>"$output_dir/public-ready.txt"
snapshot_database "$output_dir/database-before.txt"
sha256sum "$APK" >"$output_dir/apk.sha256"
sha256sum "$TEST_APK" >"$output_dir/test-apk.sha256"
docker build --target load_tools --tag "$tools_image" . \
>"$output_dir/tools-build.log"
docker build --tag "$browser_image" e2e \
>"$output_dir/browser-build.log"
docker build \
--build-arg "WNH_DEBUG_BASE_URL=$WNH_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" \
--build-arg \
"ANDROID_EMULATOR_SYSTEM_IMAGE=system-images/android-37.0/google_apis_ps16k/x86_64" \
--target emulator \
--tag "$android_image" \
android >"$output_dir/android-image-build.log"
docker run -d \
--name "$container" \
--device /dev/kvm \
"$android_image" >"$output_dir/container-id.txt"
adb wait-for-device
booted=
for _attempt in $(seq 1 90); do
booted=$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
[[ "$booted" == 1 ]] && break
sleep 2
done
if [[ "$booted" != 1 ]]; then
echo "Android cross-client emulator did not finish booting." >&2
exit 1
fi
adb shell input keyevent 82
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
adb shell cmd location set-location-enabled true
docker cp "$APK" "$container:/tmp/who-need-help-staging.apk"
docker cp "$TEST_APK" "$container:/tmp/who-need-help-staging-androidTest.apk"
adb install -r /tmp/who-need-help-staging.apk >"$output_dir/install.txt"
adb install -r /tmp/who-need-help-staging-androidTest.apk \
>"$output_dir/test-install.txt"
adb shell pm list instrumentation >"$output_dir/instrumentation.txt"
adb shell pm grant "$package" android.permission.ACCESS_FINE_LOCATION
adb shell pm grant "$package" android.permission.POST_NOTIFICATIONS
adb emu geo fix 30.5237 50.4504
network_ready=0
for _attempt in $(seq 1 45); do
if adb shell ping -c 1 -W 1 "$expected_host" \
>"$output_dir/network-current.txt" 2>&1; then
network_ready=1
break
fi
sleep 1
done
if [[ "$network_ready" -ne 1 ]]; then
echo "The Android emulator could not resolve and reach the staging host." >&2
exit 1
fi
run_fixture_tool prepare >"$output_dir/fixture-prepare.log"
prepared=1
docker run --rm \
--volume "$output_dir:/output" \
--entrypoint sh \
"$tools_image" \
-euc "chown $(id -u):$(id -g) /output/fixture.json; chmod 600 /output/fixture.json"
request_path=$(jq -r '.request.path' "$output_dir/fixture.json")
helper_login_path=$(jq -r '.helper_login_path' "$output_dir/fixture.json")
requester_email=$(jq -r '.users.requester.email' "$output_dir/fixture.json")
adb logcat -c
adb emu geo fix 30.5237 50.4504
start_android_phase exchangeMessageAndTrackingWithBrowser
tracking_started=0
for _attempt in $(seq 1 45); do
adb shell dumpsys notification --noredact >"$output_dir/notifications-current.txt"
adb shell dumpsys activity services "$package" >"$output_dir/services-current.txt"
if grep -Fq "Sharing live location" "$output_dir/notifications-current.txt" &&
grep -Fq "$service_class" "$output_dir/services-current.txt"; then
tracking_started=1
break
fi
adb emu geo fix 30.5237 50.4504 >/dev/null
sleep 1
done
if [[ "$tracking_started" -ne 1 ]]; then
echo "Android native foreground tracking did not start." >&2
exit 1
fi
run_browser_phase observe_android
wait_android_phase
tracking_stopped=0
for _attempt in $(seq 1 45); do
adb shell dumpsys activity services "$package" >"$output_dir/services-after-stop.txt"
if ! grep -Fq "$service_class" "$output_dir/services-after-stop.txt"; then
tracking_stopped=1
break
fi
sleep 1
done
if [[ "$tracking_stopped" -ne 1 ]]; then
echo "Android native foreground tracking did not stop." >&2
exit 1
fi
run_browser_phase observe_stopped
run_fixture_tool verify >"$output_dir/fixture-verify.log"
adb logcat -d -s WhoNeedHelpWebView:D AndroidRuntime:E '*:S' \
>"$output_dir/logcat-before-cleanup.txt"
if grep -Eqi \
'Main-frame load failed|net::ERR_|ERR_CERT|SSL handshake failed|FATAL EXCEPTION' \
"$output_dir/logcat-before-cleanup.txt"; then
echo "Android cross-client logcat contains a load, TLS, browser, or fatal failure." >&2
exit 1
fi
{
printf 'run_id=%s\n' "$run_id"
printf 'android_api=37.0\n'
printf 'package=%s\n' "$package"
printf 'public_origin=%s\n' "$WNH_BASE_URL"
printf 'magic_link_login=true\n'
printf 'android_chat_message_visible_in_browser=true\n'
printf 'browser_chat_reply_visible_in_android=true\n'
printf 'android_foreground_tracking_visible_in_browser=true\n'
printf 'android_tracking_stop_visible_in_browser=true\n'
printf 'retained_raw_position_after_stop=0\n'
printf 'load_tls_browser_or_fatal_errors=0\n'
} >"$output_dir/summary.txt"
echo "Android/browser public staging E2E passed."
echo "Evidence: $output_dir"

View File

@ -5,6 +5,7 @@ umask 077
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)
ENV_FILE="$ROOT/.env" ENV_FILE="$ROOT/.env"
APK="$ROOT/android/dist-staging/who-need-help-staging.apk" APK="$ROOT/android/dist-staging/who-need-help-staging.apk"
TEST_APK="$ROOT/android/dist-staging/who-need-help-staging-androidTest.apk"
run_id=$(date -u +%Y%m%d%H%M%S)-$$ run_id=$(date -u +%Y%m%d%H%M%S)-$$
image="who-need-help-android:staging-smoke-$run_id" image="who-need-help-android:staging-smoke-$run_id"
container="who-need-help-android-staging-smoke-$run_id" container="who-need-help-android-staging-smoke-$run_id"
@ -27,6 +28,11 @@ if [ ! -s "$APK" ]; then
exit 1 exit 1
fi fi
if [ ! -s "$TEST_APK" ]; then
echo "Missing staging test APK: $TEST_APK. Run scripts/android-staging-build.sh first." >&2
exit 1
fi
set -a set -a
# shellcheck source=/dev/null # shellcheck source=/dev/null
. "$ENV_FILE" . "$ENV_FILE"
@ -95,6 +101,7 @@ cleanup() {
trap cleanup EXIT HUP INT TERM trap cleanup EXIT HUP INT TERM
sha256sum "$APK" >"$output/apk.sha256" sha256sum "$APK" >"$output/apk.sha256"
sha256sum "$TEST_APK" >"$output/test-apk.sha256"
docker build \ docker build \
--build-arg "WNH_DEBUG_BASE_URL=$WNH_BASE_URL" \ --build-arg "WNH_DEBUG_BASE_URL=$WNH_BASE_URL" \
@ -139,11 +146,34 @@ 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 transition_animation_scale 0
docker exec "$container" adb shell settings put global animator_duration_scale 0 docker exec "$container" adb shell settings put global animator_duration_scale 0
docker cp "$APK" "$container:/tmp/who-need-help-staging.apk" docker cp "$APK" "$container:/tmp/who-need-help-staging.apk"
docker cp "$TEST_APK" "$container:/tmp/who-need-help-staging-androidTest.apk"
docker exec "$container" adb install -r /tmp/who-need-help-staging.apk \ docker exec "$container" adb install -r /tmp/who-need-help-staging.apk \
>"$output/install.txt" >"$output/install.txt"
docker exec "$container" adb install -r /tmp/who-need-help-staging-androidTest.apk \
>"$output/test-install.txt"
docker exec "$container" adb shell pm list instrumentation \
>"$output/instrumentation.txt"
docker exec "$container" adb shell dumpsys package "$package" \ docker exec "$container" adb shell dumpsys package "$package" \
>"$output/package.txt" >"$output/package.txt"
network_ready=false
attempt=0
while [ "$attempt" -lt 45 ]; do
if docker exec "$container" adb shell ping -c 1 -W 1 "$expected_host" \
>"$output/network-current.txt" 2>&1; then
network_ready=true
break
fi
attempt=$((attempt + 1))
sleep 1
done
if [ "$network_ready" != true ]; then
echo "The Android emulator could not resolve and reach the staging host." >&2
exit 1
fi
if ! grep -Fq "versionName=0.1.0-staging" "$output/package.txt"; then if ! grep -Fq "versionName=0.1.0-staging" "$output/package.txt"; then
echo "The installed package is not the expected staging variant." >&2 echo "The installed package is not the expected staging variant." >&2
exit 1 exit 1
@ -241,19 +271,38 @@ docker exec "$container" adb shell uiautomator dump /sdcard/safety-window.xml \
docker exec "$container" adb exec-out cat /sdcard/safety-window.xml \ docker exec "$container" adb exec-out cat /sdcard/safety-window.xml \
>"$output/safety-window.xml" >"$output/safety-window.xml"
docker exec "$container" adb exec-out screencap -p >"$output/safety.png" docker exec "$container" adb exec-out screencap -p >"$output/safety.png"
docker exec "$container" adb logcat -d >"$output/logcat-before-cleanup.txt"
set +e
docker exec "$container" adb shell am instrument -w -r \
-e class org.whoneedhelp.mobile.PublicStagingInstrumentedTest \
org.whoneedhelp.mobile.staging.test/androidx.test.runner.AndroidJUnitRunner \
>"$output/dom-results.txt" 2>&1
dom_status=$?
set -e
if [ "$dom_status" -ne 0 ] \
|| ! grep -Eq '^OK \(1 test\)$' "$output/dom-results.txt" \
|| grep -Eq 'FAILURES!!!|INSTRUMENTATION_FAILED|Process crashed' \
"$output/dom-results.txt"; then
cat "$output/dom-results.txt" >&2
echo "The staging WebView DOM assertions failed." >&2
exit 1
fi
docker exec "$container" adb logcat -d -s WhoNeedHelpWebView:D '*:S' \
>"$output/webview-after-dom.txt"
docker exec "$container" adb shell dumpsys activity activities \ docker exec "$container" adb shell dumpsys activity activities \
>"$output/activities-before-cleanup.txt" >"$output/activities-before-cleanup.txt"
if grep -Eqi \ if grep -Eqi \
'Main-frame load failed|net::ERR_|ERR_CERT|SSL handshake failed|chromium.*crash' \ 'Main-frame load failed|net::ERR_|ERR_CERT|SSL handshake failed|chromium.*crash' \
"$output/logcat-before-cleanup.txt"; then "$output/webview-after-dom.txt"; then
echo "Android staging logcat contains a public-page load or TLS failure." >&2 echo "Android staging logcat contains a public-page load or TLS failure." >&2
exit 1 exit 1
fi fi
if ! grep -Fq "$package/$activity" "$output/activities-before-cleanup.txt"; then if ! grep -Fq 'INSTRUMENTATION_CODE: -1' "$output/dom-results.txt"; then
echo "The staging activity was not observed after the deep-link load." >&2 echo "The staging DOM runner did not finish normally." >&2
exit 1 exit 1
fi fi
@ -263,8 +312,10 @@ fi
printf 'package=%s\n' "$package" printf 'package=%s\n' "$package"
printf 'public_origin=%s\n' "$WNH_BASE_URL" printf 'public_origin=%s\n' "$WNH_BASE_URL"
printf 'home_loaded=true\n' printf 'home_loaded=true\n'
printf 'home_dom_assertion=true\n'
printf 'same_origin_manifest_filter=true\n' printf 'same_origin_manifest_filter=true\n'
printf 'same_origin_deep_link_loaded=true\n' printf 'same_origin_deep_link_loaded=true\n'
printf 'safety_dom_assertion=true\n'
printf 'external_origin_manifest_filter=false\n' printf 'external_origin_manifest_filter=false\n'
printf 'load_or_tls_errors=0\n' printf 'load_or_tls_errors=0\n'
} >"$output/summary.txt" } >"$output/summary.txt"