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
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
Intent intent = new Intent(
|
||||
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<>());
|
||||
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(),
|
||||
|
|
|
|||
|
|
@ -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.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<String[]> locationPermissionLauncher;
|
||||
private ActivityResultLauncher<String[]> 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));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,10 @@
|
|||
<resources>
|
||||
<string name="app_name">Who Need Help</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="retry">Retry</string>
|
||||
<string name="close">Close</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="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. |
|
||||
| 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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>
|
||||
<% else %>
|
||||
<.button
|
||||
id="remember-login-button"
|
||||
name={@form[:remember_me].name}
|
||||
value="true"
|
||||
phx-disable-with={gettext("Logging in...")}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<.button id="send-message-button" class="btn btn-primary self-end">
|
||||
{gettext("Send")}
|
||||
</.button>
|
||||
</.form>
|
||||
</section>
|
||||
|
||||
|
|
@ -943,6 +946,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
<div :if={@tracking_active} id="live-tracking" phx-hook="LiveTracking"></div>
|
||||
<button
|
||||
:if={!@tracking_active}
|
||||
id="share-location-button"
|
||||
phx-click="start-tracking"
|
||||
class="btn btn-outline btn-sm mt-4 w-full"
|
||||
>
|
||||
|
|
@ -950,6 +954,7 @@ defmodule WhoNeedHelpWeb.RequestLive.Show do
|
|||
</button>
|
||||
<button
|
||||
:if={@tracking_active}
|
||||
id="stop-location-button"
|
||||
phx-click="stop-tracking"
|
||||
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)
|
||||
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)-$$
|
||||
image="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
|
||||
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"
|
||||
|
|
@ -95,6 +101,7 @@ cleanup() {
|
|||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
sha256sum "$APK" >"$output/apk.sha256"
|
||||
sha256sum "$TEST_APK" >"$output/test-apk.sha256"
|
||||
|
||||
docker build \
|
||||
--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 animator_duration_scale 0
|
||||
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 \
|
||||
>"$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" \
|
||||
>"$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
|
||||
echo "The installed package is not the expected staging variant." >&2
|
||||
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 \
|
||||
>"$output/safety-window.xml"
|
||||
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 \
|
||||
>"$output/activities-before-cleanup.txt"
|
||||
|
||||
if grep -Eqi \
|
||||
'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
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -Fq "$package/$activity" "$output/activities-before-cleanup.txt"; then
|
||||
echo "The staging activity was not observed after the deep-link load." >&2
|
||||
if ! grep -Fq 'INSTRUMENTATION_CODE: -1' "$output/dom-results.txt"; then
|
||||
echo "The staging DOM runner did not finish normally." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
|
@ -263,8 +312,10 @@ fi
|
|||
printf 'package=%s\n' "$package"
|
||||
printf 'public_origin=%s\n' "$WNH_BASE_URL"
|
||||
printf 'home_loaded=true\n'
|
||||
printf 'home_dom_assertion=true\n'
|
||||
printf 'same_origin_manifest_filter=true\n'
|
||||
printf 'same_origin_deep_link_loaded=true\n'
|
||||
printf 'safety_dom_assertion=true\n'
|
||||
printf 'external_origin_manifest_filter=false\n'
|
||||
printf 'load_or_tls_errors=0\n'
|
||||
} >"$output/summary.txt"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user