From b9eb8458b3cee32647aeb147115ba6c51e8086fa Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 11 Oct 2026 00:45:13 +0200 Subject: [PATCH 01/20] 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(); + }); +}); From 07e0990ecf0f8b6e7164cc07e20d054638588caf Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 11 Oct 2026 02:59:51 +0200 Subject: [PATCH 02/20] =?UTF-8?q?design(files):=20the=20one-library=20roun?= =?UTF-8?q?d=20in=20the=20lab=20=E2=80=94=20aligned=20list=20columns,=20th?= =?UTF-8?q?e=20tray,=20the=20done=20notice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Files page replaces the Downloads page: list rows with one column per value kind and a right-click menu, an app-wide download tray, a done notice that copies the link. Picked variants are logged in DESIGN.md. --- docs/DESIGN.md | 4 + frontend/src/design-lab/DesignLab.tsx | 11 + frontend/src/design-lab/filesRound.tsx | 730 +++++++++++++++++++++++++ frontend/src/design-lab/labKit.tsx | 77 ++- 4 files changed, 819 insertions(+), 3 deletions(-) create mode 100644 frontend/src/design-lab/filesRound.tsx diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 53680f95..f3588f18 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -774,3 +774,7 @@ of a full-width panel and nothing on the right. | 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) | | 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture | | 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 | +| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date | +| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click | +| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look | +| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing | diff --git a/frontend/src/design-lab/DesignLab.tsx b/frontend/src/design-lab/DesignLab.tsx index 33a3233b..bf846765 100644 --- a/frontend/src/design-lab/DesignLab.tsx +++ b/frontend/src/design-lab/DesignLab.tsx @@ -4,6 +4,7 @@ import { BatchA } from "./batchA"; import { BatchB } from "./batchB"; import { BatchC } from "./batchC"; import { BatchD } from "./batchD"; +import { FilesRound } from "./filesRound"; import { Chip } from "../components/ui/Chip"; import { Slider } from "../components/ui/NativeControls"; import { SURFACE } from "../components/ui/surfaces"; @@ -74,6 +75,9 @@ export default function DesignLab() { />