diff --git a/android/Dockerfile b/android/Dockerfile index 0e0c73d..1b6f53d 100644 --- a/android/Dockerfile +++ b/android/Dockerfile @@ -64,7 +64,10 @@ RUN android --no-metrics --sdk="${ANDROID_HOME}" sdk install \ "${ANDROID_EMULATOR_SYSTEM_IMAGE}" \ && chown -R gradle:gradle "${ANDROID_HOME}" -ARG ANDROID_EMULATOR_DATA_PARTITION_SIZE=4G +# The device suite stores only the two APKs and short-lived test data. A 1 GiB +# userdata partition passed the complete API 30/34/37 matrix on 2026-07-20; +# 4 GiB made API 34 exceed Docker's writable-layer space before boot. +ARG ANDROID_EMULATOR_DATA_PARTITION_SIZE=1G RUN apt-get update \ && apt-get install -y --no-install-recommends \ diff --git a/compose.e2e.yaml b/compose.e2e.yaml index 3af958a..83b35d7 100644 --- a/compose.e2e.yaml +++ b/compose.e2e.yaml @@ -41,4 +41,7 @@ services: condition: service_started volumes: - ${E2E_OUTPUT_DIR:?Set an ignored E2E_OUTPUT_DIR}:/work/output - networks: [internal] + # The browser needs the application/data plane on `internal` and the + # Traefik service on `ingress`. Both networks are internal in compose.yaml, + # so the isolated E2E runner still has no route to the public Internet. + networks: [internal, ingress] diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 7c0907f..465f16b 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -152,6 +152,13 @@ export function captureBrowserFailures( options: { recoverableOneTimeNavigations?: Set } = {}, ): () => void { const failures: string[] = []; + const pendingTransientNavigations = new Map(); + + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) { + pendingTransientNavigations.delete(frame.url()); + } + }); page.on("console", (message) => { if (message.type() === "error") { @@ -179,12 +186,25 @@ export function captureBrowserFailures( const recoverableOneTimeNavigation = options.recoverableOneTimeNavigations?.has(request.url()) === true && isTransientNetworkError(failure); + const pendingTransientNavigation = + request.isNavigationRequest() && + new URL(request.url()).origin === new URL(baseURL).origin && + isTransientNetworkError(failure); // MapLibre cancels in-flight raster tiles when a LiveView navigation // destroys or re-centres a map. Chromium also aborts the old document's // static assets when a bounded navigation retry replaces that document. - // Actual tile/network failures retain their engine error and remain test - // failures. + // A same-origin document navigation may fail once while the public tunnel + // changes networks. Keep that failure pending until the same URL actually + // commits; an unrecovered navigation remains a test failure. + if (pendingTransientNavigation) { + pendingTransientNavigations.set( + request.url(), + `requestfailed: ${request.method()} ${request.url()} ${failure}`, + ); + return; + } + if ( (rasterTile && cancelled) || replacedDocumentAsset || @@ -198,7 +218,10 @@ export function captureBrowserFailures( ); }); - return () => expect(failures, failures.join("\n")).toEqual([]); + return () => { + const unresolved = [...failures, ...pendingTransientNavigations.values()]; + expect(unresolved, unresolved.join("\n")).toEqual([]); + }; } export async function latestMessageID( @@ -350,18 +373,12 @@ export async function loginWithPassword( const page = await context.newPage(); for (let attempt = 1; attempt <= 3; attempt += 1) { - await gotoWithTransientRetry(page, "/users/log-in"); - const form = page.locator("#login_form_password"); - await form.getByLabel("Email").fill(email); - await form.getByLabel("Password").fill(password); - await expect(form.getByLabel("Password")).toHaveValue(password); - let navigationFailure = ""; const recordNavigationFailure = (request: Request): void => { if ( request.isNavigationRequest() && - request.method() === "POST" && - new URL(request.url()).pathname === "/users/log-in" + new URL(request.url()).origin === new URL(baseURL).origin && + isTransientNetworkError(request.failure()?.errorText ?? "") ) { navigationFailure = request.failure()?.errorText ?? ""; } @@ -370,8 +387,16 @@ export async function loginWithPassword( page.on("requestfailed", recordNavigationFailure); try { + await gotoWithTransientRetry(page, "/users/log-in"); + const form = page.locator("#login_form_password"); + await form.getByLabel("Email").fill(email); + await form.getByLabel("Password").fill(password); + await expect(form.getByLabel("Password")).toHaveValue(password); await form.getByRole("button", { name: "Log in only this time" }).click(); await expect(page).not.toHaveURL(/\/users\/log-in$/, { timeout: 10_000 }); + await expect(page.getByText(email, { exact: true })).toBeVisible({ + timeout: 10_000, + }); return { context, page, email }; } catch (error) { if (await page.getByText("Invalid email or password").isVisible()) { @@ -380,7 +405,10 @@ export async function loginWithPassword( if ( attempt === 3 || - !isTransientNetworkError(navigationFailure) + (!isTransientNetworkError(navigationFailure) && + !isTransientNetworkError( + error instanceof Error ? error.message : String(error), + )) ) { throw error; } diff --git a/scripts/android-browser-staging-e2e.sh b/scripts/android-browser-staging-e2e.sh index 4279557..b98ac05 100755 --- a/scripts/android-browser-staging-e2e.sh +++ b/scripts/android-browser-staging-e2e.sh @@ -379,6 +379,11 @@ fi run_browser_phase observe_stopped run_fixture_tool verify >"$output_dir/fixture-verify.log" +docker run --rm \ + --volume "$output_dir:/output" \ + --entrypoint sh \ + "$tools_image" \ + -euc "chown $(id -u):$(id -g) /output/fixture-verification.json; chmod 600 /output/fixture-verification.json" adb logcat -d -s WhoNeedHelpWebView:D AndroidRuntime:E '*:S' \ >"$output_dir/logcat-before-cleanup.txt" diff --git a/scripts/android-instrumentation-test.sh b/scripts/android-instrumentation-test.sh index f0d4f0e..6863766 100755 --- a/scripts/android-instrumentation-test.sh +++ b/scripts/android-instrumentation-test.sh @@ -6,7 +6,9 @@ ANDROID_ENV="$ROOT/.env" TEST_ENV="$ROOT/.env.android-test" run_id=$(date -u +%Y%m%d%H%M%S)-$$ android_api=${WNH_ANDROID_TEST_API:-37.0} -android_data_partition_size=${WNH_ANDROID_TEST_DATA_PARTITION_SIZE:-4G} +# 1G is measured against the complete API 30/34/37 suite. It can still be +# overridden for a future test that intentionally stores more device data. +android_data_partition_size=${WNH_ANDROID_TEST_DATA_PARTITION_SIZE:-1G} if ! printf '%s\n' "$android_data_partition_size" | grep -Eq '^[1-9][0-9]*[GKM]$'; then