merge: chore/e2e-retry-connect into E39 (rides the next release)
This commit is contained in:
@@ -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
|
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).
|
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
|
**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
|
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
|
machine with `npx playwright install chromium webkit`; run one project with
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { mkdirSync, readFileSync } from "node:fs";
|
import { mkdirSync, readFileSync } 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 { 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):
|
||||||
@@ -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)",
|
"E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)",
|
||||||
).not.toBe("");
|
).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.
|
// 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(
|
expect(
|
||||||
health.ok(),
|
health.ok(),
|
||||||
`localdev stack not reachable at ${BASE_URL} — start it with \`siftlode dev\``,
|
`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`
|
// 3. Authenticate. page.request shares the browser context's cookie jar, so the `session`
|
||||||
// cookie it receives is captured by storageState below.
|
// 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 },
|
data: { email: EMAIL, password: PASSWORD },
|
||||||
});
|
});
|
||||||
expect(login.ok(), "password-login failed — is the E2E account seeded?").toBeTruthy();
|
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();
|
expect(me.authenticated, "session not established after login").toBeTruthy();
|
||||||
|
|
||||||
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
|
// 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay
|
||||||
|
|||||||
@@ -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<T extends object>(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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -83,7 +84,7 @@ test("speed baseline", async ({ browser, page: page0 }) => {
|
|||||||
// the visitor's tap — the page has no autoplay).
|
// the visitor's tap — the page has no autoplay).
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
for (const t of shareTokens()) {
|
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;
|
token = t;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user