diff --git a/e2e/tests/browser-failure-policy.spec.ts b/e2e/tests/browser-failure-policy.spec.ts new file mode 100644 index 0000000..83a7d89 --- /dev/null +++ b/e2e/tests/browser-failure-policy.spec.ts @@ -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, + ]); +}); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 5c16181..2228738 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -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(); + 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 } = {}, ): () => void { const failures: string[] = []; + const consoleFailures: BrowserConsoleFailure[] = []; + const transientOpenStreetMapTileFailures: TransientExternalTileFailure[] = []; const pendingTransientNavigations = new Map(); 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([]); }; }