Block unmocked external requests in E2E tests

Surface net for the class of bug we hit in #282 — a POI test silently
relied on a live Overpass server for months because its `page.route`
mock was targeting a URL the browser no longer hit directly. CI stayed
green on coincidence (real OSM data happened to satisfy the "any
marker renders" assertion) until upstream behaviour drifted.

Add a shared Playwright fixture (e2e/fixtures/test.ts) that installs a
catch-all `page.route("**", ...)` before each test. Requests to
localhost + known tile CDNs pass through via `route.continue()`;
anything else is aborted and accumulated. At test teardown, a non-empty
blocked list throws, failing the test with the offending URLs and a
pointer to the fixture.

All seven e2e *.test.ts files updated to import test/expect (+ types)
from ./fixtures/test.

Full suite passes with this fixture in place (50/50 at --workers=2;
catch-all never fires, meaning no test currently relies on an
unallowlisted external service).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-04-19 22:42:29 +02:00
parent 9c9d53d3bd
commit f94020a93a
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
8 changed files with 69 additions and 7 deletions

62
e2e/fixtures/test.ts Normal file
View file

@ -0,0 +1,62 @@
import { test as base, expect } from "@playwright/test";
/**
* Hosts the E2E environment is allowed to contact for real. Anything
* else that reaches our catch-all route (i.e. not intercepted by a
* test-specific `page.route(...)`) is aborted, and the test fails
* at teardown with the list of blocked URLs.
*
* The trigger for this safety net was #282 a POI test that silently
* relied on live Overpass data for months because its `page.route`
* was pointing at a URL the browser no longer hit after `/api/overpass`
* was introduced. With this fixture in place, a request to
* `overpass.private.coffee` (or any other unlisted host) would abort
* and the test would surface the missing mock immediately.
*/
const EXTERNAL_ALLOWLIST: RegExp[] = [
// App origins served by Playwright's webServer
/^https?:\/\/localhost(:\d+)?\//,
/^https?:\/\/127\.0\.0\.1(:\d+)?\//,
// Tile CDNs used by the map layers in packages/map-core/src/tiles.ts
/tile\.openstreetmap\.org/,
/tile\.opentopomap\.org/,
/tile-cyclosm\.openstreetmap\.fr/,
/tile\.waymarkedtrails\.org/,
/tiles\.wmflabs\.org/,
];
export const test = base.extend({
page: async ({ page }, use) => {
const blocked: string[] = [];
// Registered first, so a test's later `page.route(pattern, ...)`
// calls take precedence (Playwright runs handlers in reverse
// registration order). When no specific mock matches, this
// catch-all decides between continue (allowlist) and abort.
await page.route("**", async (route) => {
const url = route.request().url();
if (EXTERNAL_ALLOWLIST.some((re) => re.test(url))) {
await route.continue();
return;
}
blocked.push(url);
await route.abort("failed");
});
await use(page);
if (blocked.length > 0) {
const unique = [...new Set(blocked)];
const preview = unique.slice(0, 10).join("\n ");
const more = unique.length > 10 ? `\n …and ${unique.length - 10} more` : "";
throw new Error(
`${blocked.length} unmocked external request(s) blocked:\n ${preview}${more}\n\n` +
`Either mock them with page.route(...) in the test, or add a\n` +
`pattern to EXTERNAL_ALLOWLIST in e2e/fixtures/test.ts.`,
);
}
},
});
export { expect };
export type { CDPSession, Page } from "@playwright/test";