Diagnosis (reproduced by looping cold-server runs): the recurring "cold start" failures in the auth/follow specs were a hydration race, not a Vite dep-reload (the server.warmup config already handles that — no reload events in the logs). FollowButton is an onClick button, and Playwright considers it actionable (visible + enabled) before React attaches the handler on a freshly-navigated page. A click in that window is dropped (or triggers a native form submit), so the expected state change never happens — e.g. notifications.test.ts:59 clicked "Request to follow" with no hydration wait and the button never flipped to "Requested". The existing waitForHydration guard was applied per-interaction and so was easy to forget (test 1 had it, test 3 didn't). Fix it at the navigation instead: - add gotoHydrated(page, url) = goto + waitForHydration, documented as the default for "navigate then interact with a React control" - use it before every FollowButton interaction in notifications + social - consolidate the setProfileVisibility helper (copy-pasted in three specs, two missing the hydration wait) into e2e/helpers/profile.ts, built on gotoHydrated Validation against a fresh Postgres, cold servers: notifications 0/10 failures (was the proven flaker); notifications+social+explore 0/5 (55 tests); planner unaffected (25/25). Note: two other "cold-start"-attributed flakes are NOT this race and are out of scope here — planner map-load latency (mitigated by warmup + CI retries) and the /explore directory assertion (a local artifact of the scratch DB accumulating >20 public users; CI's fresh DB stays under the page size, so it doesn't bite there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
120 lines
4.7 KiB
TypeScript
120 lines
4.7 KiB
TypeScript
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/,
|
|
];
|
|
|
|
/**
|
|
* External requests we silently drop (no allowlist, no failure). The
|
|
* Sentry browser SDK emits an envelope on first navigation even when
|
|
* `enabled: false` (the BrowserTracing integration captures the page
|
|
* load transaction before `init`'s enabled flag fully propagates). We
|
|
* don't want to allow it through to the real ingest endpoint, but we
|
|
* also don't want every test to fail with "blocked unmocked request".
|
|
*/
|
|
const SILENT_DROP: RegExp[] = [
|
|
/ingest\.[a-z]+\.sentry\.io/,
|
|
];
|
|
|
|
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;
|
|
}
|
|
if (SILENT_DROP.some((re) => re.test(url))) {
|
|
await route.abort("failed");
|
|
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.`,
|
|
);
|
|
}
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Wait until React has hydrated the current page. Vite dev injects scripts
|
|
* asynchronously, so a button/form is visible (and clickable per Playwright's
|
|
* actionability check) before its React `onClick`/`onSubmit` is wired up.
|
|
* Without this, the first click after navigation is a coin flip on a cold
|
|
* dev server — controlled inputs ignore the keystrokes and submit handlers
|
|
* never run, so the form posts empty fields or natively GETs to itself.
|
|
*
|
|
* Detects hydration by checking for React's `__reactProps$<id>` property
|
|
* which is attached during commit. CI uses production builds where this
|
|
* isn't a problem, but the check is cheap and harmless there.
|
|
*/
|
|
export async function waitForHydration(page: import("@playwright/test").Page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const el = document.body.querySelector("button, input, form, a");
|
|
if (!el) return false;
|
|
return Object.keys(el).some((k) => k.startsWith("__reactProps$"));
|
|
},
|
|
null,
|
|
{ timeout: 10000 },
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Navigate and wait for the page to be hydrated before returning.
|
|
*
|
|
* Use this (instead of bare `page.goto`) whenever the very next step
|
|
* interacts with a control whose behaviour lives in a React handler —
|
|
* notably the onClick-driven FollowButton and the profile-settings
|
|
* fetcher form. A button is "visible and enabled" per Playwright's
|
|
* actionability check before React has attached its onClick, so a click
|
|
* in that window is silently dropped (or triggers a native form submit),
|
|
* which is the dominant source of the cold-server e2e flake. Waiting for
|
|
* hydration at the navigation closes that race so individual tests can't
|
|
* forget it per interaction.
|
|
*/
|
|
export async function gotoHydrated(page: import("@playwright/test").Page, url: string) {
|
|
await page.goto(url);
|
|
await waitForHydration(page);
|
|
}
|
|
|
|
export { expect };
|
|
export type { CDPSession, Page } from "@playwright/test";
|