fix: stabilize browser and Android verification

This commit is contained in:
SimpleTest 2026-07-20 08:44:49 +03:00
parent 2d83b393f7
commit 4323ee1a7b
5 changed files with 56 additions and 15 deletions

View File

@ -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 \

View File

@ -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]

View File

@ -152,6 +152,13 @@ export function captureBrowserFailures(
options: { recoverableOneTimeNavigations?: Set<string> } = {},
): () => void {
const failures: string[] = [];
const pendingTransientNavigations = new Map<string, string>();
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;
}

View File

@ -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"

View File

@ -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