diff --git a/README.md b/README.md index d7f0a9b..6796780 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,23 @@ Results and failure diagnostics are retained by API under ignored `output/android-instrumentation/`; the exact emulator container and one-run 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 Register and confirm the first account, then explicitly bootstrap it: diff --git a/android/Dockerfile b/android/Dockerfile index fecb9bd..0e0c73d 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -148,7 +148,8 @@ RUN --mount=type=cache,target=/home/gradle/.gradle,uid=1000,gid=1000 \ "-PWNH_DEBUG_BASE_URL=${WNH_BASE_URL}" \ "-PWNH_TRACKING_MIN_TIME_MS=${WNH_TRACKING_MIN_TIME_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 @@ -157,6 +158,9 @@ USER 65532:65532 COPY --from=android-staging-sdk \ /workspace/android/app/build/outputs/apk/staging/app-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 \ /workspace/android/app/build/reports/lint-results-staging.html \ /lint-results-staging.html diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2a29d2d..7798250 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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 trackingHttpTimeoutMs = providers.gradleProperty("WNH_TRACKING_HTTP_TIMEOUT_MS").orElse("0") +val instrumentationBuildType = + providers.gradleProperty("WNH_TEST_BUILD_TYPE").orElse("debug") fun manifestOrigin(value: String): URI? = runCatching { URI(value) } @@ -25,6 +27,7 @@ android { namespace = "org.whoneedhelp.mobile" compileSdk = 37 buildToolsVersion = "37.0.0" + testBuildType = instrumentationBuildType.get() defaultConfig { applicationId = "org.whoneedhelp.mobile" diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java index 030ea8b..2d21a79 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/AndroidClientInstrumentedTest.java @@ -282,6 +282,37 @@ public final class AndroidClientInstrumentedTest { } } + @Test + public void test07MainPageFailureHasWorkingRetryAction() throws Exception { + server.disconnectNextGet("/retry-page"); + + try (ActivityScenario 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 launch(String path) { Intent intent = new Intent( Intent.ACTION_VIEW, diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java new file mode 100644 index 0000000..48e436f --- /dev/null +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/CrossClientStagingInstrumentedTest.java @@ -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 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 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 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()); + } +} diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java index e66f50e..c1899ea 100644 --- a/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/FixtureHttpServer.java @@ -38,6 +38,7 @@ final class FixtureHttpServer implements Closeable { Collections.synchronizedList(new ArrayList<>()); private volatile boolean running = true; private volatile boolean disconnectStopRequest; + private volatile String disconnectNextGetPath; FixtureHttpServer() throws IOException { URI base = URI.create(BuildConfig.BASE_URL); @@ -64,6 +65,10 @@ final class FixtureHttpServer implements Closeable { disconnectStopRequest = true; } + void disconnectNextGet(String path) { + disconnectNextGetPath = path; + } + RecordedRequest awaitRequestEndingWith(String suffix, long timeout, TimeUnit unit) throws InterruptedException { return awaitRequestAfterCount(suffix, 0, timeout, unit); @@ -179,6 +184,11 @@ final class FixtureHttpServer implements Closeable { return; } + if ("GET".equals(request.method) && request.path.equals(disconnectNextGetPath)) { + disconnectNextGetPath = null; + return; + } + if ("GET".equals(request.method)) { writeResponse( connection.getOutputStream(), diff --git a/android/app/src/androidTest/java/org/whoneedhelp/mobile/PublicStagingInstrumentedTest.java b/android/app/src/androidTest/java/org/whoneedhelp/mobile/PublicStagingInstrumentedTest.java new file mode 100644 index 0000000..3f09672 --- /dev/null +++ b/android/app/src/androidTest/java/org/whoneedhelp/mobile/PublicStagingInstrumentedTest.java @@ -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 scenario = launch("/")) { + waitForElementText( + "main-content", + "Help can be closer than you think." + ); + } + + try (ActivityScenario scenario = launch("/safety")) { + waitForElementText("main-content", "Safety rules"); + } + } + + private ActivityScenario launch(String path) { + Intent intent = new Intent( + Intent.ACTION_VIEW, + Uri.parse(BuildConfig.BASE_URL + path), + context, + MainActivity.class + ); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return ActivityScenario.launch(intent); + } + + private 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; + } +} diff --git a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java index b5bb01d..fd0272d 100644 --- a/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java +++ b/android/app/src/main/java/org/whoneedhelp/mobile/MainActivity.java @@ -2,6 +2,7 @@ package org.whoneedhelp.mobile; import android.Manifest; import android.annotation.SuppressLint; +import android.app.AlertDialog; import android.content.ActivityNotFoundException; import android.content.Intent; import android.content.pm.PackageManager; @@ -19,6 +20,7 @@ import android.webkit.SslErrorHandler; import android.webkit.WebChromeClient; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; @@ -41,6 +43,8 @@ public final class MainActivity extends ComponentActivity { private ActivityResultLauncher locationPermissionLauncher; private ActivityResultLauncher nativeTrackingPermissionLauncher; private PendingNativeTracking pendingNativeTracking; + private AlertDialog pageLoadErrorDialog; + private boolean mainFrameLoadFailed; @Override @SuppressLint("SetJavaScriptEnabled") @@ -184,6 +188,7 @@ public final class MainActivity extends ComponentActivity { @Override protected void onDestroy() { denyPendingLocation(); + dismissPageLoadError(); if (webView != null) { 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 { @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { @@ -378,9 +413,9 @@ public final class MainActivity extends ComponentActivity { SslErrorHandler handler, SslError error ) { + mainFrameLoadFailed = true; handler.cancel(); - Toast.makeText(MainActivity.this, R.string.secure_connection_failed, Toast.LENGTH_LONG) - .show(); + showPageLoadError(R.string.secure_connection_failed); } @Override @@ -390,6 +425,8 @@ public final class MainActivity extends ComponentActivity { WebResourceError error ) { if (request.isForMainFrame()) { + mainFrameLoadFailed = true; + if (BuildConfig.DEBUG) { Log.e( LOG_TAG, @@ -400,13 +437,27 @@ public final class MainActivity extends ComponentActivity { ); } - Toast.makeText(MainActivity.this, R.string.page_load_failed, Toast.LENGTH_LONG) - .show(); + showPageLoadError(R.string.page_load_failed); + } + } + + @Override + public void onReceivedHttpError( + WebView view, + WebResourceRequest request, + WebResourceResponse errorResponse + ) { + if (request.isForMainFrame()) { + mainFrameLoadFailed = true; + showPageLoadError(R.string.page_load_failed); } } @Override public void onPageStarted(WebView view, String url, Bitmap favicon) { + mainFrameLoadFailed = false; + dismissPageLoadError(); + if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "Main-frame load started: path=" + safeLogPath(Uri.parse(url))); } @@ -419,6 +470,10 @@ public final class MainActivity extends ComponentActivity { @Override public void onPageFinished(WebView view, String url) { + if (!mainFrameLoadFailed) { + dismissPageLoadError(); + } + if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "Main-frame load finished: path=" + safeLogPath(Uri.parse(url))); scheduleMapDiagnostics(view, Uri.parse(url)); diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 3faf589..3285fe7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,7 +2,10 @@ Who Need Help No app can open this link. + Page unavailable Could not load Who Need Help. Check your connection and retry. + Retry + Close The secure connection was rejected. This link type is not supported. Active help location diff --git a/docs/verification.md b/docs/verification.md index 8fdb782..bda669e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -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. | | 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. | -| 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. | | 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. | @@ -59,16 +59,19 @@ results from product limits and unknown production properties. - `mix format --check-formatted`: passed in the final run. - Android local Docker build targets `testDebugUnitTest`, `lintDebug`, `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 - destruction and remains user-stoppable. The public-staging target passed - unit tests, `lintStaging`, and `assembleStaging`; the API 37 staging smoke - loaded `/` and `/safety` from the temporary HTTPS origin with no observed - load/TLS errors. Evidence is retained at - `output/android-instrumentation/api30/20260719162757-2877617`, - `output/android-instrumentation/api34/20260719163525-3053597`, - `output/android-instrumentation/api37-0/20260719164309-3237374`, and - `output/android-staging-smoke/20260719171121-3924350`. + destruction and remains user-stoppable. The seventh forces the first + main-frame request to disconnect, checks the native recovery dialog, presses + Retry, and asserts the subsequently rendered WebView DOM. The + public-staging target passed unit tests, `lintStaging`, `assembleStaging`, + and `assembleStagingAndroidTest`; the API 37 staging smoke asserted the + rendered `/` and `/safety` DOM from the temporary HTTPS origin with no + observed load/TLS errors. Evidence is retained at + `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 `WNH_BASE_URL` failed at its dedicated preflight; the successful staging 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 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 marker updated, but the evidence badge remained stale until reload. The 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, reconnect, and active chat/tracking recovery after the serving BEAM node stops. Evidence is `output/e2e/20260719162432-2790071`. -- Android debug and staging unit/lint/build gates passed. Six instrumentation - tests passed independently on API 30, 34, and 37, including the foreground - tracking service surviving Home plus Activity destruction. The separate API - 37 public-staging smoke loaded home and Safety over the exact temporary HTTPS - origin, matched no external-origin app filter, and observed zero load/TLS - errors. +- Android debug and staging unit/lint/build gates passed. Seven instrumentation + tests passed independently on API 30, 34, and 37, including foreground + tracking surviving Home plus Activity destruction and native Retry recovery + after a forced main-frame disconnect. The separate API 37 public-staging + smoke asserted the home and Safety DOM over the exact temporary HTTPS origin, + matched no external-origin app filter, and observed zero load/TLS errors. - A real public Chromium two-user scenario registered isolated requester/helper fixtures, completed medicine discovery, acceptance, realtime chat, consent tracking, handover, both confirmations, and blind reviews. It passed 1/1 in diff --git a/e2e/Dockerfile b/e2e/Dockerfile index 83fb59d..17f41a9 100644 --- a/e2e/Dockerfile +++ b/e2e/Dockerfile @@ -5,10 +5,11 @@ WORKDIR /work COPY --chown=pwuser:pwuser package.json package-lock.json ./ 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 setup setup COPY --chown=pwuser:pwuser pwa pwa +COPY --chown=pwuser:pwuser cross-client cross-client USER pwuser diff --git a/e2e/cross-client/android-browser.spec.ts b/e2e/cross-client/android-browser.spec.ts new file mode 100644 index 0000000..992f6f6 --- /dev/null +++ b/e2e/cross-client/android-browser.spec.ts @@ -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; +} diff --git a/e2e/playwright.cross-client.config.ts b/e2e/playwright.cross-client.config.ts new file mode 100644 index 0000000..1a1f9fb --- /dev/null +++ b/e2e/playwright.cross-client.config.ts @@ -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" }], +}); diff --git a/lib/mix/tasks/wnh.staging_android_e2e.ex b/lib/mix/tasks/wnh.staging_android_e2e.ex new file mode 100644 index 0000000..9552eb6 --- /dev/null +++ b/lib/mix/tasks/wnh.staging_android_e2e.ex @@ -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 diff --git a/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex b/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex index 108b15f..5220ee3 100644 --- a/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex +++ b/lib/who_need_help_web/controllers/user_session_html/confirm.html.heex @@ -46,6 +46,7 @@ <% else %> <.button + id="remember-login-button" name={@form[:remember_me].name} value="true" phx-disable-with={gettext("Logging in...")} diff --git a/lib/who_need_help_web/live/request_live/show.ex b/lib/who_need_help_web/live/request_live/show.ex index 9108fc7..2abdd94 100644 --- a/lib/who_need_help_web/live/request_live/show.ex +++ b/lib/who_need_help_web/live/request_live/show.ex @@ -712,11 +712,14 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do class="mt-4 flex gap-2" > <.input + id="message-body" field={@message_form[:body]} placeholder={gettext("Write a safe coordination message…")} class="grow" /> - <.button class="btn btn-primary self-end">{gettext("Send")} + <.button id="send-message-button" class="btn btn-primary self-end"> + {gettext("Send")} + @@ -943,6 +946,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do