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) {
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
try {
|
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;
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
const transient = isTransientNetworkError(message);
|
const transient = isTransientNavigationError(message);
|
||||||
|
|
||||||
if (!transient || attempt === attempts) {
|
if (!transient || attempt === attempts) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -76,6 +82,98 @@ export function isTransientNetworkError(message: string): boolean {
|
||||||
].some((value) => message.includes(value));
|
].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(
|
export async function clickUntilVisible(
|
||||||
trigger: Locator,
|
trigger: Locator,
|
||||||
expected: Locator,
|
expected: Locator,
|
||||||
|
|
@ -228,6 +326,8 @@ export function captureBrowserFailures(
|
||||||
options: { recoverableOneTimeNavigations?: Set<string> } = {},
|
options: { recoverableOneTimeNavigations?: Set<string> } = {},
|
||||||
): () => void {
|
): () => void {
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
|
const consoleFailures: BrowserConsoleFailure[] = [];
|
||||||
|
const transientOpenStreetMapTileFailures: TransientExternalTileFailure[] = [];
|
||||||
const pendingTransientNavigations = new Map<string, string>();
|
const pendingTransientNavigations = new Map<string, string>();
|
||||||
|
|
||||||
page.on("framenavigated", (frame) => {
|
page.on("framenavigated", (frame) => {
|
||||||
|
|
@ -238,16 +338,10 @@ export function captureBrowserFailures(
|
||||||
|
|
||||||
page.on("console", (message) => {
|
page.on("console", (message) => {
|
||||||
if (message.type() === "error") {
|
if (message.type() === "error") {
|
||||||
const text = message.text();
|
consoleFailures.push({
|
||||||
const abortedE2eRasterTile =
|
text: message.text(),
|
||||||
text.includes("AJAXError: Failed to fetch (0):") &&
|
locationURL: message.location().url,
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -275,6 +369,10 @@ export function captureBrowserFailures(
|
||||||
request.isNavigationRequest() &&
|
request.isNavigationRequest() &&
|
||||||
new URL(request.url()).origin === new URL(baseURL).origin &&
|
new URL(request.url()).origin === new URL(baseURL).origin &&
|
||||||
isTransientNetworkError(failure);
|
isTransientNetworkError(failure);
|
||||||
|
const transientOpenStreetMapTile = isTransientOpenStreetMapTileFailure(
|
||||||
|
request.url(),
|
||||||
|
failure,
|
||||||
|
);
|
||||||
|
|
||||||
// MapLibre cancels in-flight raster tiles when a LiveView navigation
|
// MapLibre cancels in-flight raster tiles when a LiveView navigation
|
||||||
// destroys or re-centres a map. Chromium also aborts the old document's
|
// destroys or re-centres a map. Chromium also aborts the old document's
|
||||||
|
|
@ -290,6 +388,20 @@ export function captureBrowserFailures(
|
||||||
return;
|
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 (
|
if (
|
||||||
(rasterTile && cancelled) ||
|
(rasterTile && cancelled) ||
|
||||||
replacedDocumentAsset ||
|
replacedDocumentAsset ||
|
||||||
|
|
@ -304,7 +416,16 @@ export function captureBrowserFailures(
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
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([]);
|
expect(unresolved, unresolved.join("\n")).toEqual([]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user