merge: e2e page loads retried on a machine out of buffers (improvement/e2e-goto-retry)

This commit is contained in:
2026-11-04 00:53:23 +01:00
7 changed files with 81 additions and 19 deletions
+4
View File
@@ -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
+2 -2
View File
@@ -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<void> {
// 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");
+2 -1
View File
@@ -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<void> {
await page.goto(path);
await gotoRetrying(page, path);
await expect(page.getByTestId("nav-feed")).toBeVisible();
}
+26 -11
View File
@@ -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<R>(attempt: () => Promise<R>, transient: RegExp): Promise<R> {
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<T extends object>(ctx: T): T {
return new Proxy(ctx, {
@@ -18,17 +31,19 @@ export function retryConnect<T extends object>(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<R>(page: { goto(url: string): Promise<R> }, url: string): Promise<R> {
return tryAgain(() => page.goto(url), NO_BUFFER);
}
+3 -3
View File
@@ -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,
+2 -1
View File
@@ -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);
+42 -1
View File
@@ -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([]);
});
});