diff --git a/e2e/tests/browser-failure-policy.spec.ts b/e2e/tests/browser-failure-policy.spec.ts index 83a7d89..293e115 100644 --- a/e2e/tests/browser-failure-policy.spec.ts +++ b/e2e/tests/browser-failure-policy.spec.ts @@ -1,10 +1,33 @@ import { expect, test } from "@playwright/test"; import { + installDeterministicMapTileRoute, isTransientNavigationError, isTransientOpenStreetMapTileFailure, reconcileTransientOpenStreetMapConsoleFailures, } from "./helpers"; +test("the production browser verifier uses a decodable deterministic raster tile", async ({ + browser, +}) => { + const context = await browser.newContext({ serviceWorkers: "block" }); + await installDeterministicMapTileRoute(context); + const page = await context.newPage(); + + await page.setContent( + 'map tile', + ); + + await expect(page.getByAltText("map tile")).toHaveJSProperty( + "complete", + true, + ); + await expect(page.getByAltText("map tile")).toHaveJSProperty( + "naturalWidth", + 1, + ); + await context.close(); +}); + test("navigation retry covers bounded goto timeouts without treating application errors as transient", () => { expect( isTransientNavigationError( @@ -91,7 +114,7 @@ test("tile console errors remain fatal without a matching transient request fail locationURL: "https://whoneedhelp.com/assets/js/app.js", }; - expect(reconcileTransientOpenStreetMapConsoleFailures([failure], [])).toEqual([ - failure, - ]); + expect(reconcileTransientOpenStreetMapConsoleFailures([failure], [])).toEqual( + [failure], + ); }); diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts index 2228738..dd1ba15 100644 --- a/e2e/tests/helpers.ts +++ b/e2e/tests/helpers.ts @@ -21,6 +21,26 @@ export type AuthenticatedBrowser = { email: string; }; +const deterministicRasterTile = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); +const openStreetMapRasterTileURL = + /^https:\/\/tile\.openstreetmap\.org\/\d+\/\d+\/\d+\.png$/; + +export async function installDeterministicMapTileRoute( + context: BrowserContext, +): Promise { + await context.route(openStreetMapRasterTileURL, async (route) => { + await route.fulfill({ + status: 200, + contentType: "image/png", + headers: { "cache-control": "public, max-age=604800" }, + body: deterministicRasterTile, + }); + }); +} + export function projectEmail(localPart: string, projectName: string): string { const runID = process.env.E2E_RUN_ID; @@ -36,8 +56,16 @@ export function projectText(value: string, projectName: string): string { return `${value} [${projectName}]`; } -export async function newIsolatedContext(browser: Browser): Promise { - return browser.newContext({ serviceWorkers: "block" }); +export async function newIsolatedContext( + browser: Browser, +): Promise { + const context = await browser.newContext({ serviceWorkers: "block" }); + + if (process.env.E2E_DETERMINISTIC_MAP_TILES === "1") { + await installDeterministicMapTileRoute(context); + } + + return context; } export async function gotoWithTransientRetry( @@ -165,7 +193,10 @@ export function reconcileTransientOpenStreetMapConsoleFailures( if (consumeGenericResourceFailure(text)) return false; - if (genericMapLibreFetchBudget > 0 && text === "TypeError: Failed to fetch") { + if ( + genericMapLibreFetchBudget > 0 && + text === "TypeError: Failed to fetch" + ) { genericMapLibreFetchBudget -= 1; return false; } @@ -249,7 +280,9 @@ export async function setRequestLocation( .check(); } - const manualCoordinates = workspace.locator("details.request-location-manual"); + const manualCoordinates = workspace.locator( + "details.request-location-manual", + ); if (!(await manualCoordinates.getAttribute("open"))) { await manualCoordinates.locator("summary").click(); @@ -458,7 +491,9 @@ export async function waitForApplicationEmailLink( .poll( async () => { messageID = await latestMessageID(request, email); - return messageID && messageID !== previousMessageID ? messageID : undefined; + return messageID && messageID !== previousMessageID + ? messageID + : undefined; }, { message: `waiting for the Mailpit login message for ${email}`, @@ -467,7 +502,9 @@ export async function waitForApplicationEmailLink( ) .toBeTruthy(); - const response = await request.get(`${mailpitURL}/api/v1/message/${messageID}`); + const response = await request.get( + `${mailpitURL}/api/v1/message/${messageID}`, + ); expect(response.ok()).toBeTruthy(); const message = (await response.json()) as { @@ -487,7 +524,9 @@ export async function waitForApplicationEmailLink( }); if (!match) { - throw new Error(`No application link starting with ${pathPrefix} found for ${email}`); + throw new Error( + `No application link starting with ${pathPrefix} found for ${email}`, + ); } const link = new URL(match); diff --git a/scripts/production-full-e2e.sh b/scripts/production-full-e2e.sh index caaeeab..80d61a6 100755 --- a/scripts/production-full-e2e.sh +++ b/scripts/production-full-e2e.sh @@ -351,6 +351,44 @@ curl --fail --silent --show-error --max-time 10 "$BASE_URL/healthz/ready" \ >"$output_dir/readiness-before.json" snapshot "$output_dir/database-before.json" +# OSM's public raster service is best-effort and must not be bulk-tested. Probe +# exactly one canonical tile with an identifiable client and retain enough +# evidence to distinguish an unavailable provider from an application failure. +tile_headers="$output_dir/osm-tile-boundary.headers" +tile_body="$output_dir/osm-tile-boundary.png" +curl --fail --silent --show-error --max-time 10 \ + --user-agent "WhoNeedHelp-production-e2e/1.0 (+https://whoneedhelp.com)" \ + --referer "$BASE_URL/" \ + --dump-header "$tile_headers" \ + --output "$tile_body" \ + https://tile.openstreetmap.org/0/0/0.png +tile_status=$(awk 'toupper($1) ~ /^HTTP\// {status=$2} END {print status}' "$tile_headers") +tile_content_type=$( + awk ' + tolower($1) == "content-type:" { + value=$2 + sub(/\r$/, "", value) + } + END {print value} + ' "$tile_headers" +) +tile_magic=$(od -An -tx1 -N8 "$tile_body" | tr -d ' \n') + +if [[ "$tile_status" != 200 ]] || + [[ "$tile_content_type" != image/png ]] || + [[ "$tile_magic" != 89504e470d0a1a0a ]]; then + echo "The canonical OSM boundary probe did not return a valid PNG tile." >&2 + exit 1 +fi + +jq -n \ + --arg url "https://tile.openstreetmap.org/0/0/0.png" \ + --arg status "$tile_status" \ + --arg content_type "$tile_content_type" \ + --arg sha256 "$(sha256sum "$tile_body" | awk '{print $1}')" \ + '{url: $url, status: ($status | tonumber), content_type: $content_type, sha256: $sha256}' \ + >"$output_dir/osm-tile-boundary.json" + git archive "$commit" | tar -x -C "$archive_dir" # The deployed application remains the exact production commit. Only the # run-scoped fixture task and browser assertion are overlaid into throwaway @@ -394,6 +432,7 @@ docker run --rm \ --env "E2E_FIXTURE_PASSWORD=$fixture_password" \ --env "E2E_ADMIN_EMAIL=wnh-staging-e2e-$RUN_ID-admin@example.invalid" \ --env E2E_PRODUCTION_READ_ONLY=1 \ + --env E2E_DETERMINISTIC_MAP_TILES=1 \ --env HOME=/tmp \ --volume "$output_dir/browser:/work/output" \ "$browser_image" \