Compare commits

..
5 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
peter 643f305814 release: 0.116.3 (bug/lost-stylesheet) 2026-11-04 00:19:42 +01:00
peter cbe1b3bd63 chore(release): 0.116.3 2026-11-04 00:05:30 +01:00
peter ae775b383c fix(boot): a stylesheet lost on the way is asked for again
A lost stylesheet left the page unstyled until a reload, and every e2e step that finds elements by
testid still passed on it. Only axe noticed: the one-off target-size failure on eight scattered
controls. Losing the main stylesheet on purpose gives those same eight.
2026-11-03 23:32:40 +01:00
15 changed files with 324 additions and 19 deletions
+6
View File
@@ -7,6 +7,12 @@ edit the release notes and re-run the generator.
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading). Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
## 0.116.3 — 2026-11-04
### Fixed
- If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload.
## 0.116.2 — 2026-11-03 ## 0.116.2 — 2026-11-03
### Fixed ### Fixed
+1 -1
View File
@@ -1 +1 @@
0.116.2 0.116.3
+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,
+61
View File
@@ -0,0 +1,61 @@
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
// axe run ever noticed (8 controls under the target size). So this looks at the styles themselves.
/** Lose the first request for every stylesheet of the page; the ones after it go through. */
async function loseEachStylesheetOnce(page: Page): Promise<string[]> {
const lost: string[] = [];
await page.route(/\/assets\/[^/]+\.css/, async (route) => {
const url = route.request().url();
if (lost.includes(url)) return route.continue();
lost.push(url);
return route.abort("failed");
});
return lost;
}
/** Every stylesheet link of the page holds rules (a lost one still has a `sheet`, whose rules throw
* on reading), and the page's own tokens are in force: `--bg` comes from the stylesheet alone, while
* `--font-scale` would prove nothing, the app also sets it from script. */
async function expectStyled(page: Page): Promise<void> {
await expect
.poll(() =>
page.evaluate(() => {
const hasRules = (l: HTMLLinkElement) => {
try {
return (l.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
};
const links = [...document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const root = getComputedStyle(document.documentElement);
return {
links: links.length > 0,
rules: links.every(hasRules),
tokens: root.getPropertyValue("--bg").trim() !== "",
};
}),
)
.toEqual({ links: true, rules: true, tokens: true });
}
test("the app gets back a stylesheet lost on the way", async ({ page }) => {
const lost = await loseEachStylesheetOnce(page);
await openApp(page);
expect(lost.length).toBeGreaterThan(0);
await expectStyled(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 gotoRetrying(page, "/watch/no-such-token");
await expect(page.locator("#root")).not.toBeEmpty();
expect(lost.length).toBeGreaterThan(0);
await expectStyled(page);
});
@@ -1,4 +1,12 @@
[ [
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"If a stylesheet is lost on the way, the app now asks for it again, so a poor connection no longer leaves the page unstyled until a reload."
]
},
{ {
"version": "0.116.2", "version": "0.116.2",
"date": "2026-11-03", "date": "2026-11-03",
@@ -1,4 +1,12 @@
[ [
{
"version": "0.116.3",
"date": "2026-11-04",
"new": [],
"fixed": [
"Ha egy stíluslap elvész útközben, az app most újra lekéri, így a rossz kapcsolat miatt nem marad formázatlan az oldal újratöltésig."
]
},
{ {
"version": "0.116.2", "version": "0.116.2",
"date": "2026-11-03", "date": "2026-11-03",
+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([]);
});
});
@@ -0,0 +1,105 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clear, events } from "./log";
import { retryLostStylesheets } from "./stylesheetRetry";
const LOADED = { cssRules: [{}] };
// A lost stylesheet still has a `sheet`, and reading its rules throws (measured: Chromium, WebKit).
const LOST = {
get cssRules(): never {
throw new DOMException("Cannot access rules", "SecurityError");
},
};
function link(href: string, loaded: boolean, sheet: unknown = loaded ? LOADED : LOST) {
const el = document.createElement("link");
el.rel = "stylesheet";
el.href = href;
el.setAttribute("crossorigin", "");
Object.defineProperty(el, "sheet", { value: sheet, configurable: true });
document.head.append(el);
return el;
}
const links = () => [...document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')];
const logged = () => events().map((e) => e.event);
describe("retryLostStylesheets", () => {
beforeEach(() => {
vi.useFakeTimers();
clear();
});
afterEach(() => {
vi.useRealTimers();
document.head.innerHTML = "";
});
it("leaves a loaded stylesheet alone", () => {
const fine = link("/a.css", true);
retryLostStylesheets();
vi.runAllTimers();
expect(links()).toEqual([fine]);
expect(logged()).toEqual([]);
});
it("asks again for a lost one, in its place, keeping its attributes", () => {
const first = link("/a.css", true);
const lost = link("/b.css", false);
const last = link("/c.css", true);
retryLostStylesheets();
vi.advanceTimersByTime(0);
const [a, b, c] = links();
expect(a).toBe(first);
expect(b).not.toBe(lost);
expect(b!.getAttribute("href")).toBe("/b.css");
expect(b!.hasAttribute("crossorigin")).toBe(true);
expect(c).toBe(last);
expect(logged()).toEqual(["app.stylesheet_lost"]);
});
it.each([
["no sheet at all", null],
["an empty sheet", { cssRules: [] }],
])("takes a link with %s for lost too", (_, sheet) => {
const lost = link("/a.css", false, sheet);
retryLostStylesheets();
vi.advanceTimersByTime(0);
expect(links()[0]).not.toBe(lost);
});
it("says so once the new request has loaded", () => {
link("/a.css", false);
retryLostStylesheets();
vi.advanceTimersByTime(0);
links()[0]!.dispatchEvent(new Event("load"));
vi.runAllTimers();
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_recovered"]);
});
it("waits longer each time and then gives up, reporting it", () => {
link("/a.css", false);
retryLostStylesheets();
const fail = () => links()[0]!.dispatchEvent(new Event("error"));
vi.advanceTimersByTime(0);
const second = links()[0];
fail();
vi.advanceTimersByTime(999);
expect(links()[0]).toBe(second);
vi.advanceTimersByTime(1);
const third = links()[0];
expect(third).not.toBe(second);
fail();
vi.advanceTimersByTime(2999);
expect(links()[0]).toBe(third);
vi.advanceTimersByTime(1);
const fourth = links()[0];
expect(fourth).not.toBe(third);
fail();
vi.runAllTimers();
expect(links()[0]).toBe(fourth);
expect(logged()).toEqual(["app.stylesheet_lost", "app.stylesheet_gave_up"]);
});
});
+50
View File
@@ -0,0 +1,50 @@
// A stylesheet can be lost on the way (a poor line, a loaded machine), and a browser never asks again
// for one by itself: the page then stays unstyled until a reload, every control a bare browser button.
// A module script waits for the document's stylesheets (measured: a sheet held 3 s held the module
// too), so by the time this runs each link has either loaded or failed, and no event is left to wait
// for. A lost one is asked for again in its own place, so the cascade's order stays as it was built.
//
// All of these sheets are this site's own: reading the rules of a loaded one is allowed, so a throw
// says it failed (measured: Chromium, WebKit). Three tries, then the page stays as it is. A reload here
// could loop, and the unstyled page still works.
import { log } from "./log";
const RETRY_AFTER_MS = [0, 1000, 3000];
function holdsRules(link: HTMLLinkElement): boolean {
try {
return (link.sheet?.cssRules.length ?? 0) > 0;
} catch {
return false;
}
}
function askAgain(link: HTMLLinkElement, attempt: number): void {
const wait = RETRY_AFTER_MS[attempt];
if (wait === undefined) {
log.error("app.stylesheet_gave_up", "a stylesheet could not be loaded", { href: link.href });
return;
}
window.setTimeout(() => {
const next = link.cloneNode(false) as HTMLLinkElement;
next.addEventListener("load", () =>
log.info("app.stylesheet_recovered", "a lost stylesheet loaded on a new request", {
href: next.href,
attempt: attempt + 1,
}),
);
next.addEventListener("error", () => askAgain(next, attempt + 1));
link.replaceWith(next);
}, wait);
}
/** Run once, from a page's entry module. */
export function retryLostStylesheets(): void {
for (const link of document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')) {
if (holdsRules(link)) continue;
log.warn("app.stylesheet_lost", "a stylesheet was lost on the way; asking again", {
href: link.href,
});
askAgain(link, 0);
}
}
+3
View File
@@ -17,12 +17,15 @@ import "flag-icons/css/flag-icons.min.css";
import { lazyWithPreload } from "./lib/lazyWithPreload"; import { lazyWithPreload } from "./lib/lazyWithPreload";
import { installClientLog } from "./lib/logHooks"; import { installClientLog } from "./lib/logHooks";
import { installStaleBuildReload } from "./lib/staleBuild"; import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
import { appCopyOf } from "./i18n/appCopy"; import { appCopyOf } from "./i18n/appCopy";
// First, before any render: a crash or a navigation during boot belongs in the log too. // First, before any render: a crash or a navigation during boot belongs in the log too.
installClientLog(appCopyOf, typedUrlKeys()); installClientLog(appCopyOf, typedUrlKeys());
// A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk. // A tab from before the last release reloads onto the new build instead of crashing on a lazy chunk.
installStaleBuildReload(); installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
// Split by top-level route so each entry point is its own chunk: a legal page never downloads the // Split by top-level route so each entry point is its own chunk: a legal page never downloads the
// authenticated app bundle, and the app never carries it either. The legal pages live outside the // authenticated app bundle, and the app never carries it either. The legal pages live outside the
+3
View File
@@ -5,6 +5,7 @@ import WatchPage from "./watch/WatchPage";
import { T } from "./watch/watchStrings"; import { T } from "./watch/watchStrings";
import "./index.css"; import "./index.css";
import { installStaleBuildReload } from "./lib/staleBuild"; import { installStaleBuildReload } from "./lib/staleBuild";
import { retryLostStylesheets } from "./lib/stylesheetRetry";
// The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a // The entry of a public /watch/<token> link (watch.html). Whoever opens it got the link from a
// friend: signed out, often on a phone, often on mobile data — so it imports the player page and // friend: signed out, often on a phone, often on mobile data — so it imports the player page and
@@ -14,6 +15,8 @@ import { installStaleBuildReload } from "./lib/staleBuild";
// A tab left open across a release reloads onto the new build instead of failing on a chunk. // A tab left open across a release reloads onto the new build instead of failing on a chunk.
installStaleBuildReload(); installStaleBuildReload();
// A stylesheet lost on the way is asked for again rather than leaving the page unstyled.
retryLostStylesheets();
const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload }); const crashCopy = () => ({ title: T.crashTitle, subtitle: T.crashHint, reload: T.reload });