Keep production E2E independent of transient map tiles

This commit is contained in:
SimpleTest 2026-08-09 17:09:11 +03:00
parent f0cb936854
commit 246800a3fd
2 changed files with 231 additions and 13 deletions

View File

@ -0,0 +1,97 @@
import { expect, test } from "@playwright/test";
import {
isTransientNavigationError,
isTransientOpenStreetMapTileFailure,
reconcileTransientOpenStreetMapConsoleFailures,
} from "./helpers";
test("navigation retry covers bounded goto timeouts without treating application errors as transient", () => {
expect(
isTransientNavigationError(
"TimeoutError: page.goto: Timeout 20000ms exceeded.",
),
).toBe(true);
expect(
isTransientNavigationError(
"TimeoutError: locator.click: Timeout 20000ms exceeded.",
),
).toBe(false);
expect(isTransientNavigationError("Unhandled application exception")).toBe(
false,
);
});
test("only transient failures from the configured public OSM tile origin are recoverable", () => {
expect(
isTransientOpenStreetMapTileFailure(
"https://tile.openstreetmap.org/6/36/20.png",
"net::ERR_NETWORK_CHANGED",
),
).toBe(true);
expect(
isTransientOpenStreetMapTileFailure(
"https://whoneedhelp.com/assets/js/app.js",
"net::ERR_NETWORK_CHANGED",
),
).toBe(false);
expect(
isTransientOpenStreetMapTileFailure(
"https://tile.openstreetmap.org/6/36/20.png",
"net::ERR_NAME_NOT_RESOLVED",
),
).toBe(false);
expect(
isTransientOpenStreetMapTileFailure(
"https://tiles.example.test/6/36/20.png",
"net::ERR_NETWORK_CHANGED",
),
).toBe(false);
});
test("duplicate Chromium and MapLibre messages are reconciled without hiding app errors", () => {
const remaining = reconcileTransientOpenStreetMapConsoleFailures(
[
{
text: "Failed to load resource: net::ERR_NETWORK_CHANGED",
locationURL: "https://tile.openstreetmap.org/6/36/20.png",
},
{
text: "AJAXError: Failed to fetch (0): https://tile.openstreetmap.org/6/36/20.png",
locationURL: "https://whoneedhelp.com/assets/js/app.js",
},
{
text: "TypeError: Failed to fetch",
locationURL: "https://whoneedhelp.com/assets/js/app.js",
},
{
text: "Unhandled application exception",
locationURL: "https://whoneedhelp.com/assets/js/app.js",
},
],
[
{
url: "https://tile.openstreetmap.org/6/36/20.png",
failure: "net::ERR_NETWORK_CHANGED",
},
],
);
expect(remaining).toEqual([
{
text: "Unhandled application exception",
locationURL: "https://whoneedhelp.com/assets/js/app.js",
},
]);
});
test("tile console errors remain fatal without a matching transient request failure", () => {
const failure = {
text: "AJAXError: Failed to fetch (0): https://tile.openstreetmap.org/6/36/20.png",
locationURL: "https://whoneedhelp.com/assets/js/app.js",
};
expect(reconcileTransientOpenStreetMapConsoleFailures([failure], [])).toEqual([
failure,
]);
});

View File

@ -49,12 +49,18 @@ export async function gotoWithTransientRetry(
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
await page.goto(url);
// Application readiness is asserted by the caller (and by
// waitForLiveViewConnected for LiveViews). Waiting for the browser's
// full load event couples navigation to disposable third-party map
// resources and can leave an otherwise rendered page blocked on a
// stalled response. DOMContentLoaded is the stable document boundary;
// the subsequent application-specific assertion remains authoritative.
await page.goto(url, { waitUntil: "domcontentloaded" });
return;
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const transient = isTransientNetworkError(message);
const transient = isTransientNavigationError(message);
if (!transient || attempt === attempts) {
throw error;
@ -76,6 +82,98 @@ export function isTransientNetworkError(message: string): boolean {
].some((value) => message.includes(value));
}
export function isTransientNavigationError(message: string): boolean {
return (
isTransientNetworkError(message) ||
(message.includes("page.goto") &&
message.includes("Timeout") &&
message.includes("exceeded"))
);
}
const openStreetMapRasterTilePattern = /^\/\d+\/\d+\/\d+\.png$/;
export function isTransientOpenStreetMapTileFailure(
url: string,
failure: string,
): boolean {
try {
const parsed = new URL(url);
return (
parsed.origin === "https://tile.openstreetmap.org" &&
openStreetMapRasterTilePattern.test(parsed.pathname) &&
isTransientNetworkError(failure)
);
} catch {
return false;
}
}
type BrowserConsoleFailure = {
text: string;
locationURL: string;
};
type TransientExternalTileFailure = {
url: string;
failure: string;
};
export function reconcileTransientOpenStreetMapConsoleFailures(
consoleFailures: BrowserConsoleFailure[],
transientTileFailures: TransientExternalTileFailure[],
): BrowserConsoleFailure[] {
const genericResourceBudgets = new Map<string, number>();
const failedTileURLs = new Set(transientTileFailures.map(({ url }) => url));
for (const { failure } of transientTileFailures) {
genericResourceBudgets.set(
failure,
(genericResourceBudgets.get(failure) ?? 0) + 1,
);
}
let genericMapLibreFetchBudget = transientTileFailures.length > 0 ? 1 : 0;
const consumeGenericResourceFailure = (text: string): boolean => {
for (const [failure, remaining] of genericResourceBudgets) {
if (remaining > 0 && text === `Failed to load resource: ${failure}`) {
genericResourceBudgets.set(failure, remaining - 1);
return true;
}
}
return false;
};
return consoleFailures.filter(({ text, locationURL }) => {
const abortedE2eRasterTile =
text.includes("AJAXError: Failed to fetch (0):") &&
text.includes("/__e2e__/map-tile.png");
if (abortedE2eRasterTile) return false;
const knownTransientTileMessage =
failedTileURLs.has(locationURL) ||
[...failedTileURLs].some((url) => text.includes(url));
if (knownTransientTileMessage) {
consumeGenericResourceFailure(text);
return false;
}
if (consumeGenericResourceFailure(text)) return false;
if (genericMapLibreFetchBudget > 0 && text === "TypeError: Failed to fetch") {
genericMapLibreFetchBudget -= 1;
return false;
}
return true;
});
}
export async function clickUntilVisible(
trigger: Locator,
expected: Locator,
@ -228,6 +326,8 @@ export function captureBrowserFailures(
options: { recoverableOneTimeNavigations?: Set<string> } = {},
): () => void {
const failures: string[] = [];
const consoleFailures: BrowserConsoleFailure[] = [];
const transientOpenStreetMapTileFailures: TransientExternalTileFailure[] = [];
const pendingTransientNavigations = new Map<string, string>();
page.on("framenavigated", (frame) => {
@ -238,16 +338,10 @@ export function captureBrowserFailures(
page.on("console", (message) => {
if (message.type() === "error") {
const text = message.text();
const abortedE2eRasterTile =
text.includes("AJAXError: Failed to fetch (0):") &&
text.includes("/__e2e__/map-tile.png");
// Chromium reports an aborted MapLibre worker fetch as both a console
// error and a request failure when navigation destroys the old map.
// requestfailed below remains authoritative: it ignores only explicit
// browser cancellation and still records genuine tile failures.
if (!abortedE2eRasterTile) failures.push(`console: ${text}`);
consoleFailures.push({
text: message.text(),
locationURL: message.location().url,
});
}
});
@ -275,6 +369,10 @@ export function captureBrowserFailures(
request.isNavigationRequest() &&
new URL(request.url()).origin === new URL(baseURL).origin &&
isTransientNetworkError(failure);
const transientOpenStreetMapTile = isTransientOpenStreetMapTileFailure(
request.url(),
failure,
);
// MapLibre cancels in-flight raster tiles when a LiveView navigation
// destroys or re-centres a map. Chromium also aborts the old document's
@ -290,6 +388,20 @@ export function captureBrowserFailures(
return;
}
// The public application intentionally uses the external OpenStreetMap
// raster-tile origin. A browser network-change event can fail only those
// disposable visual tiles while the same-origin workflow remains healthy.
// Reconcile the duplicate Chromium/MapLibre console messages at assertion
// time, but keep every non-transient tile failure and every application
// request failure fatal.
if (transientOpenStreetMapTile) {
transientOpenStreetMapTileFailures.push({
url: request.url(),
failure,
});
return;
}
if (
(rasterTile && cancelled) ||
replacedDocumentAsset ||
@ -304,7 +416,16 @@ export function captureBrowserFailures(
});
return () => {
const unresolved = [...failures, ...pendingTransientNavigations.values()];
const unresolvedConsoleFailures =
reconcileTransientOpenStreetMapConsoleFailures(
consoleFailures,
transientOpenStreetMapTileFailures,
).map(({ text }) => `console: ${text}`);
const unresolved = [
...failures,
...unresolvedConsoleFailures,
...pendingTransientNavigations.values(),
];
expect(unresolved, unresolved.join("\n")).toEqual([]);
};
}