test(e2e): route cleanup happens by construction, not per spec

A spec that arms `page.route(...)` in a `beforeEach` and never drops the
handler races its own teardown. The context closes, an async handler is
still awaiting, its next call rejects with "Test ended.", and Playwright
attributes that to the test that had just passed. With `retries: 2` plus
`failOnFlakyTests`, which spec bites is a matter of timing.

`e2e/setup/test.ts` re-exports Playwright's `test` extended with an auto
fixture that unroutes every page and the context after each test, and
clears the page event listeners a spec subscribes to; `expect` rides
along so a spec needs one import. Auto means a spec author cannot forget
it and does not have to know it exists. Every spec now takes `test` from
there, and `e2e-route-cleanup-guard.test.ts` keeps the import boundary
closed with an allowlist that carries its reason.

The per-file `afterEach` on the cycle spec goes away — leaving both would
read as not trusting the fixture.

Not covered: a page inside a context a spec builds itself. Only
`share-documents.spec.ts` does that, and it owns those contexts.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 23:02:03 +02:00
1 parent c506b72002
commit a23ee61184
57 files changed
+311 -70

No files matched your search

+4 -2
View File
@@ -12,10 +12,12 @@ The Playwright config (`../playwright.config.ts`) builds the app (`next build`)
## Layout
- **`setup/`** — `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`setup/`** — `test.ts` (the suite's `test` + `expect` entry point), `global-setup.ts` (auth + DB seeding), `test-helpers.ts` (shared login + navigation helpers).
- **`utils/`** — fixtures such as `mock-dashboard-snapshot.ts`.
- **`*.spec.ts`** — one spec per flow: auth/login/redirect, dashboard, charts, the medication-wizard cadences (`medications-wizard-*`), insights generation + scroll restoration, doctor report, settings, mobile-viewport consistency, locale switch, accessibility (`a11y.spec.ts`), and the `v1427-*` regression set.
## Convention
## Conventions
Import `test` and `expect` from `./setup/test`, never from `@playwright/test` — types (`Page`, `Route`, …) still come from the package. The entry point extends Playwright's `test` with an automatic fixture that drops route handlers and page listeners after every test, so a handler left in flight cannot fail the test it belonged to once the context closes. `src/__tests__/e2e-route-cleanup-guard.test.ts` enforces the import boundary.
Assert against stable `data-*` attributes (`data-slot`, `data-display-step`, root display attrs), not viewport-dependent visible text. Responsive `sm:hidden` UI changes break `getByText` on desktop while the underlying element is still present — anchoring on data attributes keeps the specs stable across breakpoints. See [`../CLAUDE.md`](../CLAUDE.md).
+2 -7
View File
@@ -1,11 +1,6 @@
import AxeBuilder from "@axe-core/playwright";
import {
expect,
test,
type Locator,
type Page,
type Route,
} from "@playwright/test";
import type { Locator, Page, Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { POPULATED_SUMMARIES } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* The proxy at src/proxy.ts is the single gate enforcing auth on every
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { setTimeout as delay } from "node:timers/promises";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -9
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -175,14 +175,6 @@ test.describe("cycle + insights mobile smoke", () => {
);
});
// These handlers call route.fetch(), which needs a live page. Teardown can
// close the context while one is still in flight, and the rejected fetch
// then fails a test that had already passed. Drop the handlers first so
// nothing is left racing the close.
test.afterEach(async ({ page }) => {
await page.unrouteAll({ behavior: "ignoreErrors" });
});
test("/insights has no horizontal scroll on Pixel-5", async ({ page }) => {
await page.goto("/insights", { waitUntil: "domcontentloaded" });
await page.waitForLoadState("networkidle");
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { mockDashboardSnapshot } from "./utils/mock-dashboard-snapshot";
+2 -1
View File
@@ -16,7 +16,8 @@
* seeds a real `document_content_index` row (`ensureVaultAiFixture`) and drives
* the real list union.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -15,7 +15,8 @@
* AI e2e, so it is pinned here too. Assertions target stable data-slots, never
* viewport-dependent text.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -22,7 +22,8 @@
* the specs time out. The seeded demo user has no AI provider, so the
* no-provider state is the default.
*/
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+2 -1
View File
@@ -17,7 +17,8 @@
* they create, never on the shared doctor-flow corpus.
*/
import AxeBuilder from "@axe-core/playwright";
import { expect, test, type Page } from "@playwright/test";
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Locale-key drift smoke check — switch the cookie between EN and DE
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* Smoke checks on the login surface — must render, must wire the
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+2 -1
View File
@@ -1,4 +1,5 @@
import { expect, test, type Route } from "@playwright/test";
import type { Route } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import {
STUB_MEDICATION_ID,
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+90
View File
@@ -0,0 +1,90 @@
/**
* The E2E suite's `test` / `expect` entry point.
*
* Every spec imports from here instead of from `@playwright/test`, and the
* structural guard `src/__tests__/e2e-route-cleanup-guard.test.ts` keeps it
* that way.
*
* ## Why
*
* A spec that registers `page.route(...)` in a `beforeEach` leaves the handler
* armed until the context closes. If the handler is async — `route.fetch()`,
* an `await` before `route.fulfill()` — teardown can close the context while
* one invocation is still in flight. The callback then rejects against a dead
* target, Playwright attributes the rejection to the test that just finished,
* and a test that had already passed is reported as failed. With `retries: 2`
* plus `failOnFlakyTests` on CI, that is a release blocker whose trigger is
* timing, not code.
*
* The remedy Playwright names is `unrouteAll({ behavior: "ignoreErrors" })`
* before the test ends. Written per spec that is 36 copies of the same three
* lines and one forgotten copy away from the same failure. Written as an auto
* fixture it happens for every test in the suite, including specs nobody has
* written yet, and a spec author never has to know it exists.
*
* ## What it covers
*
* - `page.route(...)` on every page in the test's context.
* - `context.route(...)` on the context itself.
* - Page event listeners for the interactive event types a spec subscribes to.
* A listener that awaits something (a `dialog` handler, a `response` handler
* that reads a body) can reject after teardown for exactly the same reason a
* route handler can.
*
* ## What it does not cover
*
* A page that lives in a context the spec created itself — `browser.newContext()`
* inside a helper — is invisible here, because this fixture can only see the
* context Playwright built for the test. `share-documents.spec.ts` is the one
* spec that does this; it owns those contexts and has to clean them up itself.
*/
import { test as base, expect } from "@playwright/test";
/**
* Page event types cleared at teardown. Deliberately not the lifecycle events
* (`close`, `crash`, `frameattached`, …): those describe the teardown we are
* standing in, and nothing in the suite hangs work off them. These are the
* types a spec attaches a handler to, and the ones whose handler can still be
* running when the context goes away.
*/
const OBSERVED_PAGE_EVENTS = [
"console",
"dialog",
"download",
"filechooser",
"pageerror",
"popup",
"request",
"requestfailed",
"requestfinished",
"response",
"websocket",
"worker",
] as const;
export const test = base.extend<{ routeCleanup: void }>({
routeCleanup: [
async ({ context }, use) => {
await use();
// Best-effort: a spec is free to close its own context (or the browser
// can die under it), and a teardown helper must never be the thing that
// turns a green run red. Everything below is a cleanup, so a failure to
// clean up has nothing left to protect.
try {
for (const page of context.pages()) {
await page.unrouteAll({ behavior: "ignoreErrors" });
for (const event of OBSERVED_PAGE_EVENTS) {
await page.removeAllListeners(event, { behavior: "ignoreErrors" });
}
}
await context.unrouteAll({ behavior: "ignoreErrors" });
} catch {
// Target already gone — nothing left to unroute.
}
},
{ auto: true },
],
});
export { expect };
+2 -1
View File
@@ -24,7 +24,8 @@
* broader corpus (`ensureVaultFixture`) supplies the ≥50 documents the picker
* cap test and the not-attached foreign-id probe need.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import type { Browser, Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import pg from "pg";
import { E2E_USER, STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* v1.4.27 MB6 — public surfaces.
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
/**
* /api/version is the public, unauthenticated endpoint that drives
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import {
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
@@ -0,0 +1,156 @@
/**
* Structural guard on the E2E suite's route-cleanup boundary.
*
* A spec that arms `page.route(...)` in a `beforeEach` and never drops the
* handler races its own teardown: the context closes, an in-flight handler
* rejects, and Playwright reports a test that had already passed as failed.
* `e2e/setup/test.ts` removes the possibility with an auto fixture, but only
* for specs that take their `test` from there — a spec that imports straight
* from `@playwright/test` silently opts out and looks identical in review.
*
* So the entry point is the invariant, and this file is what keeps it true.
* It is a tripwire, not a proof: it shows nobody has changed the import
* boundary without editing this file. That the fixture actually cleans up is
* a runtime property, proven by the suite itself.
*/
import { describe, it, expect } from "vitest";
import { readFileSync, globSync } from "node:fs";
import { join, sep } from "node:path";
const E2E = join(process.cwd(), "e2e");
/** The shared entry point, relative to `e2e/`. */
const ENTRY = "setup/test.ts";
/**
* Files allowed to import `test` from `@playwright/test` directly, each with
* the reason it has to.
*/
const DIRECT_IMPORT_ALLOWLIST: Record<string, string> = {
// The entry point itself — it extends Playwright's base `test`, which it
// cannot do without importing it.
[ENTRY]: "defines the extended `test` every other file imports",
};
function e2eFiles(): string[] {
return globSync("**/*.ts", { cwd: E2E })
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(E2E, rel), "utf8");
}
/**
* The file with its comments removed. The entry point documents the very API
* calls it makes, so a naive substring match would be satisfied by the prose
* alone — a fixture gutted to a no-op would still read as compliant.
*/
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
}
/**
* Every `from "<module>"` import statement in a file, as
* `{ module, specifiers }` — `specifiers` is the raw brace body, or `"*"` for
* a namespace import.
*/
function imports(src: string): Array<{ module: string; specifiers: string }> {
const found: Array<{ module: string; specifiers: string }> = [];
for (const m of src.matchAll(
/import\s+(type\s+)?\{([\s\S]*?)\}\s*from\s*"([^"]+)"/g,
)) {
// `import type { … }` imports no runtime binding at all.
found.push({ module: m[3], specifiers: m[1] ? "" : m[2] });
}
for (const m of src.matchAll(/import\s+\*\s+as\s+\w+\s+from\s*"([^"]+)"/g)) {
found.push({ module: m[1], specifiers: "*" });
}
return found;
}
/** Does this file import the runtime `test` binding from `module`? */
function importsTestFrom(src: string, module: string): boolean {
return imports(src)
.filter((i) => i.module === module)
.some(
(i) =>
i.specifiers === "*" ||
i.specifiers
.split(",")
.map((s) => s.trim())
.filter((s) => s && !s.startsWith("type "))
.some((s) => /^test(\s+as\s+\w+)?$/.test(s)),
);
}
describe("the E2E suite takes `test` from the shared entry point", () => {
it("no file outside the allowlist imports `test` from @playwright/test", () => {
const offenders = e2eFiles().filter(
(rel) =>
!(rel in DIRECT_IMPORT_ALLOWLIST) &&
importsTestFrom(read(rel), "@playwright/test"),
);
expect(offenders).toEqual([]);
});
it("the allowlist names only files that exist and still need the exemption", () => {
for (const rel of Object.keys(DIRECT_IMPORT_ALLOWLIST)) {
expect(
importsTestFrom(read(rel), "@playwright/test"),
`${rel} is allowlisted but no longer imports \`test\` directly — drop the entry`,
).toBe(true);
}
});
it("every spec imports `test` from the entry point", () => {
const specs = e2eFiles().filter((rel) => rel.endsWith(".spec.ts"));
// Sanity: the sweep found the suite, not an empty directory.
expect(specs.length).toBeGreaterThan(40);
const missing = specs.filter((rel) => {
const src = read(rel);
// Specs sit at `e2e/<name>.spec.ts`, so the entry resolves as
// `./setup/test`. A nested spec would need a different prefix; none
// exists, and this assertion is what would surface one.
return !importsTestFrom(src, "./setup/test");
});
expect(missing).toEqual([]);
});
});
describe("the entry point still cleans up", () => {
const src = stripComments(read(ENTRY));
it("registers the cleanup as an automatic fixture", () => {
// `auto: true` is the whole point: a spec author cannot forget it and does
// not have to declare it. Without the flag the fixture is dead code.
expect(src).toMatch(/\{\s*auto:\s*true\s*\}/);
});
it("drops route handlers on both the page and the context", () => {
expect(src).toMatch(/page\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/);
expect(src).toMatch(/context\.unrouteAll\(\{\s*behavior:\s*"ignoreErrors"/);
});
it("drops page event listeners", () => {
expect(src).toMatch(/removeAllListeners\(/);
});
it("cleans up after the test body, not before it", () => {
// `await use()` first, cleanup after — a fixture that cleaned up before
// the test would unroute the handlers a `beforeEach` had just armed.
const useAt = src.indexOf("await use()");
const unrouteAt = src.indexOf("unrouteAll(");
expect(useAt).toBeGreaterThan(-1);
expect(unrouteAt).toBeGreaterThan(useAt);
});
it("exports both `test` and `expect`, so a spec needs one import", () => {
expect(src).toMatch(/export const test\b/);
expect(src).toMatch(/export \{ expect \}/);
});
});