feat: verify Android staging interoperability
This commit is contained in:
parent
7c1d24d356
commit
b96d443a96
17
README.md
17
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
|
`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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
52
e2e/cross-client/android-browser.spec.ts
Normal file
52
e2e/cross-client/android-browser.spec.ts
Normal 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;
|
||||||
|
}
|
||||||
37
e2e/playwright.cross-client.config.ts
Normal file
37
e2e/playwright.cross-client.config.ts
Normal 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" }],
|
||||||
|
});
|
||||||
474
lib/mix/tasks/wnh.staging_android_e2e.ex
Normal file
474
lib/mix/tasks/wnh.staging_android_e2e.ex
Normal 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
|
||||||
|
|
@ -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...")}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
407
scripts/android-browser-staging-e2e.sh
Executable file
407
scripts/android-browser-staging-e2e.sh
Executable 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"
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user