diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md index 8ea9c7c9..f5b1bb2a 100644 --- a/frontend/e2e/README.md +++ b/frontend/e2e/README.md @@ -24,6 +24,10 @@ outlives an `expect` timeout); Node does not, so a request from the test side fa that one error again and passes every other failure straight on. Don't run two e2e suites on the same machine at once. +**A page load the machine had no buffer for** — on the same host a page load can fail at once with +`net::ERR_NO_BUFFER_SPACE`. Every page load goes through `gotoRetrying` (same file), which tries that +one error again; a test that calls `page.goto` itself fails `src/lib/e2eRetryConnect.test.ts`. + **Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile) — except `a11y`, `member` and `devices`, which run on chromium only (`CHROMIUM_ONLY`; why is in the `docs/gates.md` row). Install the engines once per diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts index 2c68e31a..e68328c8 100644 --- a/frontend/e2e/global.setup.ts +++ b/frontend/e2e/global.setup.ts @@ -4,7 +4,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { MEMBER_STORAGE, SEED_FILE } from "./helpers"; -import { retryConnect } from "./retryConnect"; +import { gotoRetrying, retryConnect } from "./retryConnect"; /** * One-time setup for the characterization net (the `setup` project the tests depend on): @@ -52,7 +52,7 @@ async function signIn(page: Page, email: string, path: string): Promise { // and player flows this net locks all open "/" expecting the feed; the opt-out is the one // Settings → Appearance → Startup writes. Both flags are the account's local state, held for the // account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData). - await page.goto("/"); + await gotoRetrying(page, "/"); await page.evaluate((id: number) => { localStorage.setItem("siftlode.owner", String(id)); localStorage.setItem("siftlode.lang", "en"); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index 3358e076..e725a78a 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { expect, type Page } from "@playwright/test"; +import { gotoRetrying } from "./retryConnect"; /** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and * `devices.spec.ts` run from it. */ @@ -16,7 +17,7 @@ export function seeded(key: string): string[] { /** Open the app and wait for the authenticated shell (the nav rail) to be ready. */ export async function openApp(page: Page, path = "/"): Promise { - await page.goto(path); + await gotoRetrying(page, path); await expect(page.getByTestId("nav-feed")).toBeVisible(); } diff --git a/frontend/e2e/retryConnect.ts b/frontend/e2e/retryConnect.ts index 78df1e2c..3c256426 100644 --- a/frontend/e2e/retryConnect.ts +++ b/frontend/e2e/retryConnect.ts @@ -11,6 +11,19 @@ const METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "fetch const NO_CONNECTION = /\bconnect ETIMEDOUT\b/; const WAITS_MS = [500, 1000, 2000, 4000, 8000]; +/** `attempt` again after each wait while it fails with `transient`; any other failure goes on. */ +async function tryAgain(attempt: () => Promise, transient: RegExp): Promise { + for (let i = 0; ; i++) { + try { + return await attempt(); + } catch (e) { + const wait = WAITS_MS[i]; + if (wait === undefined || !transient.test(String((e as Error)?.message))) throw e; + await new Promise((r) => setTimeout(r, wait)); + } + } +} + /** `ctx` with its request methods trying a connect that timed out again (see above). */ export function retryConnect(ctx: T): T { return new Proxy(ctx, { @@ -18,17 +31,19 @@ export function retryConnect(ctx: T): T { const v: unknown = Reflect.get(target, key, target); if (typeof v !== "function") return v; if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target); - return async (...args: unknown[]) => { - for (let i = 0; ; i++) { - try { - return await v.apply(target, args); - } catch (e) { - const wait = WAITS_MS[i]; - if (wait === undefined || !NO_CONNECTION.test(String((e as Error)?.message))) throw e; - await new Promise((r) => setTimeout(r, wait)); - } - } - }; + return (...args: unknown[]) => + tryAgain(() => Promise.resolve(v.apply(target, args)), NO_CONNECTION); }, }); } + +// A page load has a failure of its own on the same machine: `net::ERR_NO_BUFFER_SPACE`, the socket +// call refused for the moment. It stopped a release's e2e at its first page (0.116.3, with nothing +// else running; 0.107.3 aborted on it too), and the unchanged rerun passed. A browser does not try +// a main document again by itself, and the request never left, so this does. The suite's every page +// load goes through here (`e2eRetryConnect.test.ts`). +const NO_BUFFER = /\bERR_NO_BUFFER_SPACE\b/; + +export function gotoRetrying(page: { goto(url: string): Promise }, url: string): Promise { + return tryAgain(() => page.goto(url), NO_BUFFER); +} diff --git a/frontend/e2e/speed.measure.ts b/frontend/e2e/speed.measure.ts index c10d2727..753fd12f 100644 --- a/frontend/e2e/speed.measure.ts +++ b/frontend/e2e/speed.measure.ts @@ -1,7 +1,7 @@ import { test, type Page } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { mkdirSync, writeFileSync } from "node:fs"; -import { retryConnect } from "./retryConnect"; +import { gotoRetrying, retryConnect } from "./retryConnect"; /** * The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which @@ -78,7 +78,7 @@ test("speed baseline", async ({ browser, page: page0 }) => { for (let i = 0; i < RUNS; i++) { const ctx = await browser.newContext(); const page = await ctx.newPage(); - await page.goto("/"); + await gotoRetrying(page, "/"); // Timed inside the page, every frame: an `expect` would read the clock on its own retry // schedule (100 ms … 1 s steps), not when the card appeared. const at = await page.waitForFunction( @@ -108,7 +108,7 @@ test("speed baseline", async ({ browser, page: page0 }) => { for (let i = 0; i < RUNS; i++) { const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const page = await ctx.newPage(); - await page.goto(`/watch/${token}`); + await gotoRetrying(page, `/watch/${token}`); await page.waitForFunction(() => document.querySelector("video, audio"), null, { polling: "raf", timeout: 30_000, diff --git a/frontend/e2e/stylesheet.spec.ts b/frontend/e2e/stylesheet.spec.ts index de2995b7..a1a596e8 100644 --- a/frontend/e2e/stylesheet.spec.ts +++ b/frontend/e2e/stylesheet.spec.ts @@ -1,5 +1,6 @@ import { test, expect, type Page } from "@playwright/test"; import { openApp } from "./helpers"; +import { gotoRetrying } from "./retryConnect"; // A request can be lost on the way on a loaded machine or a poor line. A lost stylesheet leaves the // whole page unstyled, and every test that finds its elements by testid still passes on it: only an @@ -53,7 +54,7 @@ test("the app gets back a stylesheet lost on the way", async ({ page }) => { test("a public /watch link gets back a stylesheet lost on the way", async ({ page }) => { const lost = await loseEachStylesheetOnce(page); // No such link: the page says so, and it is the same entry with the same stylesheet. - await page.goto("/watch/no-such-token"); + await gotoRetrying(page, "/watch/no-such-token"); await expect(page.locator("#root")).not.toBeEmpty(); expect(lost.length).toBeGreaterThan(0); await expectStyled(page); diff --git a/frontend/src/lib/e2eRetryConnect.test.ts b/frontend/src/lib/e2eRetryConnect.test.ts index d87e3aa3..5b262efb 100644 --- a/frontend/src/lib/e2eRetryConnect.test.ts +++ b/frontend/src/lib/e2eRetryConnect.test.ts @@ -1,7 +1,8 @@ // The e2e suite's request wrapper (e2e/retryConnect.ts): a connect that never came about is tried // again, as every TCP stack would; any other failure, or an answer, goes straight to the test. +import { readdirSync, readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; -import { retryConnect } from "../../e2e/retryConnect"; +import { gotoRetrying, retryConnect } from "../../e2e/retryConnect"; const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080"); @@ -39,3 +40,43 @@ describe("retryConnect", () => { vi.useRealTimers(); }); }); + +// The text Playwright gave when this stopped a release's e2e at its first page load (2026-11-04). +const noBuffer = () => new Error("page.goto: net::ERR_NO_BUFFER_SPACE at http://127.0.0.1:8080/"); + +describe("gotoRetrying", () => { + it("tries a page load the machine had no buffer for again", async () => { + vi.useFakeTimers(); + const goto = vi.fn().mockRejectedValueOnce(noBuffer()).mockResolvedValue("response"); + const answer = gotoRetrying({ goto }, "/feed"); + await vi.runAllTimersAsync(); + await expect(answer).resolves.toBe("response"); + expect(goto).toHaveBeenCalledTimes(2); + expect(goto).toHaveBeenLastCalledWith("/feed"); + vi.useRealTimers(); + }); + + it("passes any other failure straight on, untried", async () => { + const goto = vi.fn().mockRejectedValue(new Error("page.goto: net::ERR_CONNECTION_REFUSED")); + await expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_CONNECTION_REFUSED"); + expect(goto).toHaveBeenCalledTimes(1); + }); + + it("gives up after its last wait, with the error", async () => { + vi.useFakeTimers(); + const goto = vi.fn().mockRejectedValue(noBuffer()); + const settled = expect(gotoRetrying({ goto }, "/")).rejects.toThrow("ERR_NO_BUFFER_SPACE"); + await vi.runAllTimersAsync(); + await settled; + expect(goto).toHaveBeenCalledTimes(6); + vi.useRealTimers(); + }); + + it("is the only way the suite loads a page", () => { + const dir = new URL("../../e2e/", import.meta.url); + const direct = readdirSync(dir) + .filter((f) => f.endsWith(".ts") && f !== "retryConnect.ts") + .filter((f) => /\.(goto|reload)\(/.test(readFileSync(new URL(f, dir), "utf-8"))); + expect(direct).toEqual([]); + }); +});