Compare commits

...
2 Commits
Author SHA1 Message Date
peter f7e036abef merge: e2e page loads retried on a machine out of buffers (improvement/e2e-goto-retry) 2026-11-04 00:53:23 +01:00
peter f2de8f73aa test(e2e): a page load the machine had no buffer for is tried again
net::ERR_NO_BUFFER_SPACE stopped a release at its first page with nothing else running, and the
unchanged rerun passed. Every page load of the suite now goes through one helper, held by a test.
2026-11-04 00:26:50 +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 that one error again and passes every other failure straight on. Don't run two e2e suites on the
same machine at once. 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 **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 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 `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 { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { MEMBER_STORAGE, SEED_FILE } from "./helpers"; 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): * 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 // 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 // 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). // account the browser records as their owner (`siftlode.owner`, see storage.ts claimLocalData).
await page.goto("/"); await gotoRetrying(page, "/");
await page.evaluate((id: number) => { await page.evaluate((id: number) => {
localStorage.setItem("siftlode.owner", String(id)); localStorage.setItem("siftlode.owner", String(id));
localStorage.setItem("siftlode.lang", "en"); localStorage.setItem("siftlode.lang", "en");
+2 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { expect, type Page } from "@playwright/test"; 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 /** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` and
* `devices.spec.ts` run from it. */ * `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. */ /** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
export async function openApp(page: Page, path = "/"): Promise<void> { export async function openApp(page: Page, path = "/"): Promise<void> {
await page.goto(path); await gotoRetrying(page, path);
await expect(page.getByTestId("nav-feed")).toBeVisible(); 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 NO_CONNECTION = /\bconnect ETIMEDOUT\b/;
const WAITS_MS = [500, 1000, 2000, 4000, 8000]; 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). */ /** `ctx` with its request methods trying a connect that timed out again (see above). */
export function retryConnect<T extends object>(ctx: T): T { export function retryConnect<T extends object>(ctx: T): T {
return new Proxy(ctx, { 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); const v: unknown = Reflect.get(target, key, target);
if (typeof v !== "function") return v; if (typeof v !== "function") return v;
if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target); if (typeof key !== "string" || !METHODS.has(key)) return v.bind(target);
return async (...args: unknown[]) => { return (...args: unknown[]) =>
for (let i = 0; ; i++) { tryAgain(() => Promise.resolve(v.apply(target, args)), NO_CONNECTION);
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));
}
}
};
}, },
}); });
} }
// 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 { test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process"; import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs"; 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 * 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++) { for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext(); const ctx = await browser.newContext();
const page = await ctx.newPage(); 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 // 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. // schedule (100 ms … 1 s steps), not when the card appeared.
const at = await page.waitForFunction( const at = await page.waitForFunction(
@@ -108,7 +108,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
for (let i = 0; i < RUNS; i++) { for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(`/watch/${token}`); await gotoRetrying(page, `/watch/${token}`);
await page.waitForFunction(() => document.querySelector("video, audio"), null, { await page.waitForFunction(() => document.querySelector("video, audio"), null, {
polling: "raf", polling: "raf",
timeout: 30_000, timeout: 30_000,
+2 -1
View File
@@ -1,5 +1,6 @@
import { test, expect, type Page } from "@playwright/test"; import { test, expect, type Page } from "@playwright/test";
import { openApp } from "./helpers"; 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 // 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 // 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 }) => { test("a public /watch link gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page); const lost = await loseEachStylesheetOnce(page);
// No such link: the page says so, and it is the same entry with the same stylesheet. // 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(); await expect(page.locator("#root")).not.toBeEmpty();
expect(lost.length).toBeGreaterThan(0); expect(lost.length).toBeGreaterThan(0);
await expectStyled(page); 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 // 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. // 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 { 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"); const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080");
@@ -39,3 +40,43 @@ describe("retryConnect", () => {
vi.useRealTimers(); 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([]);
});
});