From b9eb8458b3cee32647aeb147115ba6c51e8086fa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 11 Oct 2026 00:45:13 +0200 Subject: [PATCH] test(e2e): a localhost connect the machine dropped is tried again on the test side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Windows host a burst of test traffic can drop a loopback connect now and then (every port, any program — measured on the dev desktop). The browser resends it and only waits; Node (libuv) sends a loopback SYN once and fails at ~300 ms with "connect ETIMEDOUT". `e2e/retryConnect.ts` wraps Playwright's request context so that one error is tried again (0.5 / 1 / 2 / 4 / 8 s, the system's own retry waits) while every other failure goes straight to the test; global.setup's health check, login and /api/me and speed.measure's share probe go through it. Unit-tested; e2e 61/61. --- frontend/e2e/README.md | 7 ++++ frontend/e2e/global.setup.ts | 11 +++++-- frontend/e2e/retryConnect.ts | 34 ++++++++++++++++++++ frontend/e2e/speed.measure.ts | 3 +- frontend/src/lib/e2eRetryConnect.test.ts | 41 ++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 frontend/e2e/retryConnect.ts create mode 100644 frontend/src/lib/e2eRetryConnect.test.ts diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md index fba13bd3..2fa68d23 100644 --- a/frontend/e2e/README.md +++ b/frontend/e2e/README.md @@ -17,6 +17,13 @@ siftlode e2e-seed # (re)seed the test account only, no tests The localdev stack must be up (`siftlode dev`). `global.setup.ts` reseeds the account and logs in once, sharing the session via `e2e/.auth/state.json` (git-ignored). +**A dropped localhost connect** — on a Windows host, a burst of test traffic can drop a loopback +connect now and then (every port, any program). The browser resends it and only waits (a loader +outlives an `expect` timeout); Node does not, so a request from the test side fails with +`connect ETIMEDOUT`. Those requests go through `retryConnect` (`e2e/retryConnect.ts`), which tries +that one error again and passes every other failure straight on. Don't run two e2e suites on the +same machine at once. + **Browsers** — every flow runs in three projects: `chromium` (Desktop Chrome), `webkit` (Desktop Safari) and `ipad` (iPad gen 7 landscape: 1080×810, touch, mobile). Install the engines once per machine with `npx playwright install chromium webkit`; run one project with diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts index 782ca13e..5c706ecf 100644 --- a/frontend/e2e/global.setup.ts +++ b/frontend/e2e/global.setup.ts @@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { retryConnect } from "./retryConnect"; /** * One-time setup for the characterization net (the `setup` project the tests depend on): @@ -32,8 +33,12 @@ setup("seed + authenticate", async ({ page }) => { "E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)", ).not.toBe(""); + // These requests run in Node (sharing the page's cookie jar), so a localhost connect the machine + // dropped is tried again (retryConnect). + const api = retryConnect(page.request); + // 1. Preflight: fail fast (with a useful message) if the stack isn't running. - const health = await page.request.get("/healthz"); + const health = await api.get("/healthz"); expect( health.ok(), `localdev stack not reachable at ${BASE_URL} — start it with \`siftlode dev\``, @@ -66,12 +71,12 @@ setup("seed + authenticate", async ({ page }) => { // 3. Authenticate. page.request shares the browser context's cookie jar, so the `session` // cookie it receives is captured by storageState below. - const login = await page.request.post("/auth/password-login", { + const login = await api.post("/auth/password-login", { data: { email: EMAIL, password: PASSWORD }, }); expect(login.ok(), "password-login failed — is the E2E account seeded?").toBeTruthy(); - const me = await (await page.request.get("/api/me")).json(); + const me = await (await api.get("/api/me")).json(); expect(me.authenticated, "session not established after login").toBeTruthy(); // 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay diff --git a/frontend/e2e/retryConnect.ts b/frontend/e2e/retryConnect.ts new file mode 100644 index 00000000..78df1e2c --- /dev/null +++ b/frontend/e2e/retryConnect.ts @@ -0,0 +1,34 @@ +// Playwright's request context (it runs in Node), trying a connection again when it never came about — +// as every TCP stack does. Measured on the Windows dev desktop (2026-10-10, the work-group-timesheet +// suite): now and then, in bursts of 1–15 s, a connect to localhost is dropped — every port, IPv4 and +// IPv6, any program, no Docker needed; set off by any big test run on the machine. Every other client +// sends its SYN again and gets in a moment later, but Node (libuv) sends a loopback SYN only once on +// Windows and fails at ~300 ms with "connect ETIMEDOUT". The request never left this side, so trying it +// again is safe: the waits the system's own retries take (0.5, 1, 2, 4, 8 s). Anything else — a refused +// port, a reset, an answer — goes straight to the test. (The browser resends its own SYN: a page only +// waits, and nothing here can or should hide that.) +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]; + +/** `ctx` with its request methods trying a connect that timed out again (see above). */ +export function retryConnect(ctx: T): T { + return new Proxy(ctx, { + get(target, key) { + 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)); + } + } + }; + }, + }); +} diff --git a/frontend/e2e/speed.measure.ts b/frontend/e2e/speed.measure.ts index 8a661ca8..139bbd25 100644 --- a/frontend/e2e/speed.measure.ts +++ b/frontend/e2e/speed.measure.ts @@ -1,6 +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"; /** * The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which @@ -83,7 +84,7 @@ test("speed baseline", async ({ browser, page: page0 }) => { // the visitor's tap — the page has no autoplay). let token: string | null = null; for (const t of shareTokens()) { - if ((await page0.request.get(`/api/public/watch/${t}`)).ok()) { + if ((await retryConnect(page0.request).get(`/api/public/watch/${t}`)).ok()) { token = t; break; } diff --git a/frontend/src/lib/e2eRetryConnect.test.ts b/frontend/src/lib/e2eRetryConnect.test.ts new file mode 100644 index 00000000..d87e3aa3 --- /dev/null +++ b/frontend/src/lib/e2eRetryConnect.test.ts @@ -0,0 +1,41 @@ +// 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 { describe, expect, it, vi } from "vitest"; +import { retryConnect } from "../../e2e/retryConnect"; + +const noConnection = () => new Error("apiRequestContext.get: connect ETIMEDOUT 127.0.0.1:8080"); + +describe("retryConnect", () => { + it("tries a connect that timed out again, and hands back the answer that came", async () => { + vi.useFakeTimers(); + const get = vi + .fn() + .mockRejectedValueOnce(noConnection()) + .mockRejectedValueOnce(noConnection()) + .mockResolvedValue("ok"); + const api = retryConnect({ get }); + const answer = api.get("/healthz"); + await vi.runAllTimersAsync(); + await expect(answer).resolves.toBe("ok"); + expect(get).toHaveBeenCalledTimes(3); + expect(get).toHaveBeenLastCalledWith("/healthz"); + vi.useRealTimers(); + }); + + it("passes any other failure straight on, untried", async () => { + const post = vi.fn().mockRejectedValue(new Error("read ECONNRESET")); + await expect(retryConnect({ post }).post("/auth/password-login")).rejects.toThrow("ECONNRESET"); + expect(post).toHaveBeenCalledTimes(1); + }); + + it("gives up after its last wait, with the connect error", async () => { + vi.useFakeTimers(); + const get = vi.fn().mockRejectedValue(noConnection()); + const answer = retryConnect({ get }).get("/api/me"); + const settled = expect(answer).rejects.toThrow("ETIMEDOUT"); + await vi.runAllTimersAsync(); + await settled; + expect(get).toHaveBeenCalledTimes(6); // the first try and five more + vi.useRealTimers(); + }); +});