Keep production E2E independent of transient map tiles
This commit is contained in:
parent
f0cb936854
commit
246800a3fd
97
e2e/tests/browser-failure-policy.spec.ts
Normal file
97
e2e/tests/browser-failure-policy.spec.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user