From 9251918e08f6294a14df7077a4134996fd73e351 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 16 Oct 2026 01:13:31 +0200 Subject: [PATCH 1/4] =?UTF-8?q?chore(test):=20the=20DB=20lane=20runs=20on?= =?UTF-8?q?=20every=20core=20=E2=80=94=20each=20pytest-xdist=20worker=20ge?= =?UTF-8?q?ts=20its=20own=20database?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docker-compose.test.yml | 5 +- backend/pytest.ini | 7 +- backend/requirements-dev.txt | 3 + backend/tests/db_per_worker.py | 94 +++++++++++++++++++++++++ docs/development.md | 3 + frontend/src/lib/gates.contract.test.ts | 5 +- frontend/src/lib/gates.snapshot.json | 3 +- 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 backend/tests/db_per_worker.py diff --git a/backend/docker-compose.test.yml b/backend/docker-compose.test.yml index e3d18c67..c493d648 100644 --- a/backend/docker-compose.test.yml +++ b/backend/docker-compose.test.yml @@ -16,6 +16,9 @@ services: POSTGRES_DB: siftlode_test tmpfs: - /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run + # One database per pytest-xdist worker, each through the app's own pool (5 + 10 overflow): the default 100 + # connections would run out at 8 workers. + command: ["postgres", "-c", "max_connections=300"] healthcheck: test: ["CMD-SHELL", "pg_isready -U siftlode -d siftlode_test"] interval: 1s @@ -37,4 +40,4 @@ services: - ./:/app working_dir: /app # Coverage is on demand, not in the gate: see docs/development.md. - command: ["python", "-m", "pytest"] + command: ["python", "-m", "pytest", "-n", "auto"] diff --git a/backend/pytest.ini b/backend/pytest.ini index cc36472b..697f5814 100644 --- a/backend/pytest.ini +++ b/backend/pytest.ini @@ -7,4 +7,9 @@ # not an unknown marker that quietly does nothing. testpaths = tests pythonpath = . -addopts = -q -rs --strict-markers +# `-p tests.db_per_worker`: under pytest-xdist (`-n`) every worker gets its own database — DATABASE_URL + `_gw`, +# created on first use — because the `db` fixture's TRUNCATE-per-test isolation assumes one process per database. +# It is a plugin so it runs before conftest imports the app (whose settings read DATABASE_URL once). Serial: no-op. +# The file is a verbatim copy of a shared tool (see its docstring); change the source, not the copy. +addopts = -q -rs --strict-markers -p tests.db_per_worker +db_per_worker_env = DATABASE_URL diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index e681a5f8..d8179199 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -16,3 +16,6 @@ pytest-cov==7.1.0 ruff==0.15.21 httpx2==2.13.0 pip-audit==2.10.1 +# pytest-xdist: the DB lane runs on every core (`-n auto` in docker-compose.test.yml); tests/db_per_worker.py gives +# each worker its own database. Same tests, same strictness — only side by side. +pytest-xdist==3.8.0 diff --git a/backend/tests/db_per_worker.py b/backend/tests/db_per_worker.py new file mode 100644 index 00000000..ab7b60c0 --- /dev/null +++ b/backend/tests/db_per_worker.py @@ -0,0 +1,94 @@ +"""Give every pytest-xdist worker its own database, so a suite that shares one test database can run in parallel. + +Generic (source of truth: the memory repo's tools/pytest-parallel/db_per_worker.py); a project carries a verbatim copy +next to its conftest — tests often run in a container that cannot see this repo — and +`tools/pytest-parallel/sync-check.sh ` reports drift. Keep the copy byte-identical; change it here. + +Use, as a pytest plugin (it then runs before any conftest, i.e. before the app reads its settings): + pytest.ini: addopts = -p tests.db_per_worker (the copy's import path; `python -m pytest` puts cwd on sys.path) + db_per_worker_env = DATABASE_URL (the environment variable that holds the test database) +or call `apply("")` yourself before the app is imported. The variable may hold: + - a Postgres URL (`postgresql://…/name`, `postgresql+psycopg://…`): the worker gets `name_gw`, created from the + base database's server if missing (empty — the project's own schema fixture migrates it, as for one process); + - a SQLite URL (`sqlite:///path.db`) or a plain file path: the worker gets `path-gw.db`. +Without xdist (no PYTEST_XDIST_WORKER) or with the variable unset it changes nothing, so a serial run is unaffected. +""" + +from __future__ import annotations + +import os +import re +from urllib.parse import urlsplit, urlunsplit + +import pytest + +_PG = re.compile(r"^postgres(?:ql)?(?:\+\w+)?://", re.I) + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addini( + "db_per_worker_env", + "env var holding the test database (a Postgres URL, SQLite URL or file path)", + ) + + +@pytest.hookimpl(tryfirst=True) +def pytest_load_initial_conftests(early_config: pytest.Config) -> None: + var = early_config.getini("db_per_worker_env") + if var: + apply(str(var)) + + +def apply(var: str) -> str | None: + worker = os.environ.get("PYTEST_XDIST_WORKER") + value = os.environ.get(var) + if not worker or not value: + return value + if _PG.match(value): + new = _postgres(value, worker) + else: + new = _file(value, worker) + os.environ[var] = new + return new + + +def _postgres(url: str, worker: str) -> str: + parts = urlsplit(url) + base = parts.path.lstrip("/") + name = f"{base}_{worker}" + if not re.fullmatch(r"\w+", name): + raise ValueError(f"db_per_worker: refusing an unusual database name {name!r}") + # The server, through the BASE database (it exists; `postgres` may not be reachable for this role). + plain = urlunsplit( + (re.sub(r"\+\w+$", "", parts.scheme), parts.netloc, parts.path, parts.query, "") + ) + _create_if_missing(plain, name) + return urlunsplit((parts.scheme, parts.netloc, f"/{name}", parts.query, parts.fragment)) + + +def _create_if_missing(dsn: str, name: str) -> None: + try: + import psycopg # psycopg 3 + + conn = psycopg.connect(dsn, autocommit=True) + except ImportError: + import psycopg2 # fallback for projects on psycopg2 + + conn = psycopg2.connect(dsn) + conn.autocommit = True + try: + cur = conn.cursor() + cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (name,)) + if cur.fetchone() is None: + cur.execute(f'CREATE DATABASE "{name}"') # name checked to be \w+ above + finally: + conn.close() + + +def _file(value: str, worker: str) -> str: + prefix = "" + m = re.match(r"^(sqlite(?:\+\w+)?:///)(.*)$", value, re.I) + if m: + prefix, value = m.group(1), m.group(2) + root, ext = os.path.splitext(value) + return f"{prefix}{root}-{worker}{ext}" diff --git a/docs/development.md b/docs/development.md index bd9171e3..5a8d8114 100644 --- a/docs/development.md +++ b/docs/development.md @@ -107,6 +107,9 @@ Coverage is on demand, never enforced and not in the gate (E35): `npm run test - `frontend/`, and for the backend `docker compose -f backend/docker-compose.test.yml run --rm test python -m pytest --cov=app` (then `… down -v`). +The DB lane runs on every core (`pytest -n auto`, pytest-xdist): `tests/db_per_worker.py` gives each worker its own +database (`siftlode_test_gw`, created and migrated on first use), so the same tests run side by side. A serial +run (no `-n`) still works and uses `siftlode_test` itself. The Playwright suite (`npm run e2e`) runs against the `:8080` stack built from your tree; the release requires it. Before an image is pushed, it is scanned with Trivy (HIGH/CRITICAL with a fix fails; exceptions in `.trivyignore.yaml`, each with a reason and an expiry). diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 9f793a86..220a4303 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -552,7 +552,10 @@ describe("the gate register (docs/gates.md)", () => { "[pytest]", "testpaths = tests", "pythonpath = .", - "addopts = -q -rs --strict-markers", + // `-p tests.db_per_worker` selects nothing and filters nothing: under `-n` it hands each worker its + // own database (DATABASE_URL + `_gw`), so the same tests can run side by side + "addopts = -q -rs --strict-markers -p tests.db_per_worker", + "db_per_worker_env = DATABASE_URL", ]); }); diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index a478db2d..ce60c8c7 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1232,6 +1232,7 @@ " POSTGRES_DB: siftlode_test", " tmpfs:", " - /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run", + " command: [\"postgres\", \"-c\", \"max_connections=300\"]", " healthcheck:", " test: [\"CMD-SHELL\", \"pg_isready -U siftlode -d siftlode_test\"]", " interval: 1s", @@ -1250,7 +1251,7 @@ " volumes:", " - ./:/app", " working_dir: /app", - " command: [\"python\", \"-m\", \"pytest\"]" + " command: [\"python\", \"-m\", \"pytest\", \"-n\", \"auto\"]" ], "frontend/.prettierignore": [ "dist", From 926e5e9899f677d911d74f7c8a2a281d6571d303 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 16 Oct 2026 02:53:30 +0200 Subject: [PATCH 2/4] chore(gate): check.sh can list its lanes and run one of them --list prints the lane names, --only "" runs that one lane, so a runner can schedule the lanes itself and a red lane can be re-run on its own. An --only run reports that lane, never the gate, and an unknown name exits 2 instead of passing with nothing run; the contract test holds both. --- docs/development.md | 2 ++ frontend/src/lib/gates.contract.test.ts | 34 ++++++++++++++++++++++- frontend/src/lib/gates.snapshot.json | 30 ++++++++++++++++----- scripts/check.sh | 36 ++++++++++++++++++++----- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/docs/development.md b/docs/development.md index 5a8d8114..13dd22f4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -44,6 +44,8 @@ A change is done when this is green, from the repo root: bash scripts/check.sh # every lane bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one +bash scripts/check.sh --list # the lane names, one per line (runs nothing) +bash scripts/check.sh --only "vitest" # one lane, by its exact name — never "the gate" bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only ``` diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 220a4303..9a487c82 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -1,6 +1,7 @@ +import { execFileSync, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { relative } from "node:path"; +import { relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ESLint, type Linter } from "eslint"; import prettierConfig from "eslint-config-prettier"; @@ -375,6 +376,37 @@ describe("the gate snapshot (gates.snapshot.json)", () => { }, 120_000); }); +// Git for Windows' own bash: on a Windows PATH `bash` is often WSL's, which cannot run the script. +function bash(): string { + if (process.platform !== "win32") return "bash"; + const exec = execFileSync("git", ["--exec-path"], { encoding: "utf8" }).trim(); + return resolve(exec, "../../../bin/bash.exe"); +} +const checkSh = (...args: string[]) => + spawnSync(bash(), ["scripts/check.sh", ...args], { cwd: REPO, encoding: "utf8" }); + +// A runner that schedules lanes one by one learns them from `--list` and runs each with `--only`; a +// lane missing from the list would be a lane it never runs, and an `--only` that passed with nothing +// run would be a green about nothing. +describe("the gate script's lane list (check.sh --list / --only)", () => { + it("lists every lane the script runs, in order", () => { + const declared = [...read("scripts/check.sh").matchAll(/^\s*lane "([^"]+)"/gm)].map( + (m) => m[1], + ); + const listed = checkSh("--list"); + expect(listed.status).toBe(0); + expect(listed.stdout.trim().split("\n")).toEqual(declared); + const lint = checkSh("lint", "--list").stdout.trim().split("\n"); + expect(lint.length).toBeGreaterThan(0); + expect(declared.slice(0, lint.length)).toEqual(lint); + }); + it("refuses an unknown lane instead of passing with nothing run", () => { + const r = checkSh("--only", "no such lane"); + expect(r.status).toBe(2); + expect(r.stdout).not.toContain("green"); + }); +}); + describe("the gate register (docs/gates.md)", () => { it("lists every ESLint rule narrowed, by scope", async () => { expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]); diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index ce60c8c7..d6230d64 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1299,16 +1299,24 @@ "cd \"$(dirname \"$0\")/..\" || exit 2", "mode=all", "brief=0", - "for arg in \"$@\"; do", - " case \"$arg\" in", - " all | lint) mode=\"$arg\" ;;", + "list=0", + "only=\"\"", + "usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only \"\"]' >&2; exit 2; }", + "while [ $# -gt 0 ]; do", + " case \"$1\" in", + " all | lint) mode=\"$1\" ;;", " --brief) brief=1 ;;", + " --list) list=1 ;;", + " --only) [ $# -ge 2 ] || usage; only=\"$2\"; shift ;;", " esac", + " shift", "done", + "[ \"$list\" = 1 ] && [ -n \"$only\" ] && usage", "export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below", "TESTCOMPOSE=backend/docker-compose.test.yml", "failed=()", "timings=()", + "ran=0", "ms() { date +%s%3N; }", "secs() { awk -v t=\"$1\" 'BEGIN { printf \"%.1f\", t / 1000 }'; }", "took=\"\"", @@ -1316,7 +1324,7 @@ " took=\"$(secs $(($(ms) - $3)))\"", " timings+=(\"$(printf '%8s s %s' \"$took\" \"$1\")$([ \"$2\" = red ] && echo ' (red)')\")", " if [ -n \"${GATE_TIMES_FILE:-}\" ]; then", - " printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"$(date -u +%FT%TZ)\" \"$mode\" \"$1\" \"$took\" \"$2\" \\", + " printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"$(date -u +%FT%TZ)\" \"${only:+only}${only:-$mode}\" \"$1\" \"$took\" \"$2\" \\", " \"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)\" >>\"$GATE_TIMES_FILE\"", " fi", " return 0", @@ -1324,6 +1332,9 @@ "lane() { # lane — run it, remember a failure, never stop", " local name=\"$1\" t0 result=ok", " shift", + " if [ \"$list\" = 1 ]; then echo \"$name\"; return 0; fi", + " [ -n \"$only\" ] && [ \"$name\" != \"$only\" ] && return 0", + " ran=$((ran + 1))", " t0=\"$(ms)\"", " if [ \"$brief\" = 0 ]; then", " echo \">> $name…\"", @@ -1407,14 +1418,21 @@ " lane \"pip-audit (backend lock)\" pip_audit", " lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high", "fi", + "[ \"$list\" = 1 ] && exit 0", + "if [ -n \"$only\" ] && [ \"$ran\" = 0 ]; then", + " echo \"no lane named '$only' in mode $mode — see: scripts/check.sh $mode --list\" >&2", + " exit 2", + "fi", + "scope=\"gate ($mode)\"", + "[ -n \"$only\" ] && scope=\"lane '$only' only (not the gate)\"", "echo \">> lane times:\"", "printf '%s\\n' \"${timings[@]}\"", "echo \"$(printf '%8s' \"$(secs $(($(ms) - gate_t0)))\") s total\"", "if [ ${#failed[@]} -eq 0 ]; then", - " echo \">> gate ($mode): green ✓\"", + " echo \">> $scope: green ✓\"", " exit 0", "fi", - "echo \">> gate ($mode): RED — ${failed[*]}\" >&2", + "echo \">> $scope: RED — ${failed[*]}\" >&2", "exit 1" ] } diff --git a/scripts/check.sh b/scripts/check.sh index 41e1217e..626255be 100644 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -9,6 +9,10 @@ # bash scripts/check.sh lint the static lanes only: no tests, no build, no audits # … --brief (either mode) a lane's output only when it fails, and only its first # error lines — for an agent, whose context the full log would flood +# … --list the lane names of that mode, one per line — runs nothing +# … --only "" that one lane (an exact name from --list): a re-run of a red lane, or a +# runner that schedules lanes itself. Its green is that lane's, never "the +# gate"; an unknown name exits 2 rather than passing with nothing run # GATE_TIMES_FILE= … also append each lane's time as a TSV row (UTC, mode, lane, seconds, # ok|red, HEAD's tree) — to compare runs; every run prints the times anyway # @@ -20,18 +24,26 @@ set -u cd "$(dirname "$0")/.." || exit 2 mode=all brief=0 -for arg in "$@"; do - case "$arg" in - all | lint) mode="$arg" ;; +list=0 +only="" +usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only ""]' >&2; exit 2; } +while [ $# -gt 0 ]; do + case "$1" in + all | lint) mode="$1" ;; --brief) brief=1 ;; - *) echo "usage: scripts/check.sh [lint] [--brief]" >&2; exit 2 ;; + --list) list=1 ;; + --only) [ $# -ge 2 ] || usage; only="$2"; shift ;; + *) usage ;; esac + shift done +[ "$list" = 1 ] && [ -n "$only" ] && usage export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below TESTCOMPOSE=backend/docker-compose.test.yml failed=() timings=() +ran=0 ms() { date +%s%3N; } secs() { awk -v t="$1" 'BEGIN { printf "%.1f", t / 1000 }'; } # How long each lane took: a line after the lane, a table at the end, and — when GATE_TIMES_FILE names a @@ -42,7 +54,7 @@ timed() { # timed — sets $took (no subshell: it mus took="$(secs $(($(ms) - $3)))" timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')") if [ -n "${GATE_TIMES_FILE:-}" ]; then - printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$mode" "$1" "$took" "$2" \ + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "${only:+only}${only:-$mode}" "$1" "$took" "$2" \ "$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE" fi return 0 @@ -50,6 +62,9 @@ timed() { # timed — sets $took (no subshell: it mus lane() { # lane — run it, remember a failure, never stop local name="$1" t0 result=ok shift + if [ "$list" = 1 ]; then echo "$name"; return 0; fi + [ -n "$only" ] && [ "$name" != "$only" ] && return 0 + ran=$((ran + 1)) t0="$(ms)" if [ "$brief" = 0 ]; then echo ">> $name…" @@ -163,12 +178,19 @@ if [ "$mode" = all ]; then lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high fi +[ "$list" = 1 ] && exit 0 +if [ -n "$only" ] && [ "$ran" = 0 ]; then + echo "no lane named '$only' in mode $mode — see: scripts/check.sh $mode --list" >&2 + exit 2 +fi +scope="gate ($mode)" +[ -n "$only" ] && scope="lane '$only' only (not the gate)" echo ">> lane times:" printf '%s\n' "${timings[@]}" echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total" if [ ${#failed[@]} -eq 0 ]; then - echo ">> gate ($mode): green ✓" + echo ">> $scope: green ✓" exit 0 fi -echo ">> gate ($mode): RED — ${failed[*]}" >&2 +echo ">> $scope: RED — ${failed[*]}" >&2 exit 1 From 5ba567923931f50857ffc1412a95e4c0d83f15c3 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 17 Oct 2026 01:08:15 +0200 Subject: [PATCH 3/4] test: tests that assumed an idle machine wait for what they assert, or carry a budget for their work Under a busy machine (the gate's lanes side by side, 12 busy cores) these went red at random: a press landed while the button was still busy, an answer was settled before it was asked for, a page grew past one wait's budget, a lazy chunk was transformed inside a 1 s wait, focus was taken a frame after the press. They now wait for the condition they depend on, preload the chunks they open, and the tests that do heavy work (whole-repo scans, the type-aware linter, module resets) carry an explicit budget naming that work. The whole suite four times under 12 busy-loop processes: red 4/4 before, 0/4 after. --- .../components/Feed.countStale.dom.test.tsx | 13 ++- .../src/components/Files.menu.dom.test.tsx | 17 +++- .../src/components/FilesRail.dom.test.tsx | 11 ++- frontend/src/components/PlexMenu.dom.test.tsx | 5 + .../PlexPlayer.controls.dom.test.tsx | 14 +-- .../components/authCardStates.dom.test.tsx | 10 +- .../src/components/formAlerts.dom.test.tsx | 5 +- .../ui/DataTable.progressive.dom.test.tsx | 17 +++- .../src/lib/accountPinnedStores.dom.test.ts | 9 +- frontend/src/lib/api/facade.test.ts | 4 +- .../src/lib/buttonPrimitive.contract.test.ts | 32 ++++--- frontend/src/lib/diagnostics.dom.test.ts | 7 +- .../src/lib/diagnostics.session.dom.test.ts | 7 +- .../src/lib/inputPrimitive.contract.test.ts | 26 ++++-- frontend/src/lib/planCodes.contract.test.ts | 3 +- .../src/lib/removedFeatures.contract.test.ts | 3 +- frontend/src/lib/verifyEntry.dom.test.tsx | 91 ++++++++++--------- frontend/src/watchEntry.contract.test.ts | 3 +- 18 files changed, 181 insertions(+), 96 deletions(-) diff --git a/frontend/src/components/Feed.countStale.dom.test.tsx b/frontend/src/components/Feed.countStale.dom.test.tsx index 6de9c37b..4c952366 100644 --- a/frontend/src/components/Feed.countStale.dom.test.tsx +++ b/frontend/src/components/Feed.countStale.dom.test.tsx @@ -75,10 +75,12 @@ describe("Feed — the count while a new search's count loads", () => { it("dims the previous count and marks it busy until the new one lands", async () => { vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never); let settle: (v: unknown) => void = () => {}; - vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) => - f.q === "kitchen" - ? Promise.resolve({ count: 1937, facets: {} }) - : new Promise((r) => (settle = r))) as never); + const summary = vi + .spyOn(api, "feedSummary") + .mockImplementation(((f: FeedFilters) => + f.q === "kitchen" + ? Promise.resolve({ count: 1937, facets: {} }) + : new Promise((r) => (settle = r))) as never); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} }); const pill = () => screen.getByTestId("item-count"); @@ -91,6 +93,9 @@ describe("Feed — the count while a new search's count loads", () => { await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true")); expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such + // The pill turns busy in the render that changes the search, and the count is asked for in an + // effect after it: answer only once it has been asked, or the answer goes to no one. + await waitFor(() => expect(summary).toHaveBeenCalledWith(expect.objectContaining({ q: "a" }))); settle({ count: 178991, facets: {} }); await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991")); expect(pill().getAttribute("aria-busy")).toBeNull(); diff --git a/frontend/src/components/Files.menu.dom.test.tsx b/frontend/src/components/Files.menu.dom.test.tsx index 6c1d8276..277ae036 100644 --- a/frontend/src/components/Files.menu.dom.test.tsx +++ b/frontend/src/components/Files.menu.dom.test.tsx @@ -7,7 +7,7 @@ // back home; a shared file is the owner's, so its menu only plays, saves, cuts or drops it from my // list. i18n is key-passthrough, so labels read as keys. import { useState } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { act, cleanup, @@ -118,6 +118,17 @@ import Files from "./Files"; import { FILES_LIST_CAP, type QueueJob } from "../lib/api"; import { noteQueue, resetQueueWatch, useFinished } from "../lib/downloadQueue"; +// The page loads its dialogs lazily, and the first test to open one would pay for transforming the +// chunk inside its own 1 s wait for the dialog — over that on a busy machine. Loaded once, up front. +beforeAll(async () => { + await Promise.all([ + import("./FilePreview"), + import("./ShareDialog"), + import("./UploadDialog"), + import("./VideoEditor"), + ]); +}, 60_000); + const file = (id: number, extra: Partial = {}): LibraryFile => ({ id, @@ -452,7 +463,9 @@ describe("Files · my file's row", () => { }); }); -describe("Files · a library past the list's cap", () => { +// Its cases draw a full page of FILES_LIST_CAP rows, some of them twice (a sort, then its answer): +// up to ~0.8 s idle, past 5 s when the full suite's workers share a busy machine. +describe("Files · a library past the list's cap", { timeout: 30_000 }, () => { it("a search under the cap runs on the rows; one past it asks the server, so an old file is found", () => { mocks.folders = [YT]; mocks.files = [file(1, { display_name: "Recept" }), file(2)]; diff --git a/frontend/src/components/FilesRail.dom.test.tsx b/frontend/src/components/FilesRail.dom.test.tsx index 8f583daa..c767fe25 100644 --- a/frontend/src/components/FilesRail.dom.test.tsx +++ b/frontend/src/components/FilesRail.dom.test.tsx @@ -35,6 +35,12 @@ import FilesRail from "./FilesRail"; import { api, type FileFolder } from "../lib/api"; import { defaultLayout } from "../lib/panelLayout"; +// The share dialog is a lazy chunk, and the test that opens it would pay for transforming it inside +// its own wait for the dialog — over that on a busy machine. Loaded once, up front. +beforeAll(async () => { + await import("./ShareDialog"); +}, 60_000); + beforeAll(() => { class Stub { observe() {} @@ -251,10 +257,7 @@ describe("FilesRail · a folder shared by link", () => { .getAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === "files.folder.shareLink")!, ); - // The dialog is its own chunk, fetched on first use: slower than a re-render under a full run. - expect( - await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }), - ).toBeTruthy(); + expect(await screen.findByText("downloads.share.folderLinkHint")).toBeTruthy(); expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy(); }); }); diff --git a/frontend/src/components/PlexMenu.dom.test.tsx b/frontend/src/components/PlexMenu.dom.test.tsx index bb7636c1..8062a6c8 100644 --- a/frontend/src/components/PlexMenu.dom.test.tsx +++ b/frontend/src/components/PlexMenu.dom.test.tsx @@ -394,6 +394,11 @@ describe("a Plex title's menu — Add to collection", () => { // Retry from the keyboard: the focused button goes as the list loads, and focus must not be left // on with the menu open — it lands on the first row that came back. const retry = (await screen.findByText("common.retry")).closest("button")!; + // The menu takes the focus a frame after it opens. Wait for that: a frame that came only after the + // press would put the focus back on the panel over the hand-over below — a test faster than a + // frame, not a reader, who cannot press a Retry they have not seen painted yet. + const panel = retry.closest("[data-floating-ui-portal]")!; + await vi.waitFor(() => expect(panel.contains(document.activeElement)).toBe(true)); retry.focus(); fireEvent.click(retry); const beta = await checkRow("Beta"); diff --git a/frontend/src/components/PlexPlayer.controls.dom.test.tsx b/frontend/src/components/PlexPlayer.controls.dom.test.tsx index 68312d4f..c870d70f 100644 --- a/frontend/src/components/PlexPlayer.controls.dom.test.tsx +++ b/frontend/src/components/PlexPlayer.controls.dom.test.tsx @@ -28,6 +28,11 @@ import { pushLayer, removeLayer } from "../lib/layerStack"; type Media = HTMLMediaElement & { _t?: number; _paused?: boolean }; +// The info overlay is a lazy chunk, and the first test to open it would pay for transforming it +// inside its own wait for the overlay — over that on a busy machine. Loaded once, up front. +beforeAll(async () => { + await import("./PlexInfo"); +}, 60_000); beforeAll(() => { class Stub { observe() {} @@ -504,8 +509,7 @@ describe("a collection sibling in the info overlay", () => { await playingAt("/media/A", 100); fireEvent.click(screen.getByLabelText("plex.player.more")); fireEvent.click(await menuItem("plex.player.infoBtn")); - // The overlay is a lazy chunk: its first import takes a while under the test runner. - const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }); + const restart = await screen.findByRole("button", { name: "card.restart" }); fireEvent.click(restart); await waitFor(() => expect(lastStart(session)).toEqual(["B", 0])); expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed @@ -526,9 +530,7 @@ describe("a collection sibling in the info overlay", () => { const asked = session.mock.calls.length; fireEvent.click(screen.getByLabelText("plex.player.more")); fireEvent.click(await menuItem("plex.player.infoBtn")); - fireEvent.click( - await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }), - ); + fireEvent.click(await screen.findByRole("button", { name: "card.restart" })); await waitFor(() => expect(video()._t).toBe(0)); expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload expect(video().paused).toBe(false); @@ -578,7 +580,7 @@ describe("a Restart of the paused film whose session starts past 0", () => { act(() => void video().pause()); fireEvent.click(screen.getByLabelText("plex.player.more")); fireEvent.click(await menuItem("plex.player.infoBtn")); - const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }); + const restart = await screen.findByRole("button", { name: "card.restart" }); const plays = play.mock.calls.length; fireEvent.click(restart); await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1)); diff --git a/frontend/src/components/authCardStates.dom.test.tsx b/frontend/src/components/authCardStates.dom.test.tsx index 1e2f630f..b1a3e933 100644 --- a/frontend/src/components/authCardStates.dom.test.tsx +++ b/frontend/src/components/authCardStates.dom.test.tsx @@ -25,6 +25,10 @@ beforeAll(async () => { await import("./Welcome"); }, 60_000); +// Warm or not, each case still loads and evaluates the whole Welcome tree afresh after its module +// reset: ~0.1 s idle, past 5 s when the full suite's workers share a busy machine (seen at 5.01 s). +const CASE_BUDGET = 30_000; + afterEach(() => { cleanup(); vi.resetModules(); @@ -85,7 +89,7 @@ function refusal( return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail })); } -describe("a refused sign-in becomes a named state", () => { +describe("a refused sign-in becomes a named state", { timeout: CASE_BUDGET }, () => { it("names the address the account is on, not the one that was typed", async () => { const passwordLogin = vi.fn(); const { HttpError } = await mountCard("/", { passwordLogin }); @@ -164,7 +168,7 @@ describe("a refused sign-in becomes a named state", () => { // password" form purely because a `#reset=` fragment was present, and only told the user after they // had typed one. Opening a dead reset link is ordinary — the token lives an hour, mail is slow, and // whoever got two mails clicks the older one. -describe("an emailed reset link that is already finished", () => { +describe("an emailed reset link that is already finished", { timeout: CASE_BUDGET }, () => { it("refuses to draw a form that cannot succeed", async () => { await mountCard("/#reset=dead-token", { checkPasswordReset: () => Promise.resolve({ ok: false }), @@ -250,7 +254,7 @@ describe("an emailed reset link that is already finished", () => { }); }); -describe("the access-request notice from a denied Google sign-in", () => { +describe("the access-request notice from a denied Google sign-in", { timeout: CASE_BUDGET }, () => { it("names the address the request was recorded for", async () => { await mountCard("/#access=requested&email=nptemu%40gmail.com"); diff --git a/frontend/src/components/formAlerts.dom.test.tsx b/frontend/src/components/formAlerts.dom.test.tsx index 8885c565..9951555e 100644 --- a/frontend/src/components/formAlerts.dom.test.tsx +++ b/frontend/src/components/formAlerts.dom.test.tsx @@ -6,7 +6,7 @@ // // i18n is mocked to a key-passthrough; the API is stubbed so nothing reaches out. import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); @@ -42,6 +42,9 @@ async function failTwice(fail: () => void, message: string) { fail(); const first = await screen.findByRole("alert"); expect(first.textContent).toBe(message); + // The wizard's Next is busy until its failed step returns (a tick after the message shows), and a + // busy button ignores a press: press again only once nothing on the form says it is working. + await waitFor(() => expect(document.querySelector('[aria-busy="true"]')).toBeNull()); fail(); const second = screen.getByRole("alert"); expect(second.textContent).toBe(message); diff --git a/frontend/src/components/ui/DataTable.progressive.dom.test.tsx b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx index 5d870751..095a7556 100644 --- a/frontend/src/components/ui/DataTable.progressive.dom.test.tsx +++ b/frontend/src/components/ui/DataTable.progressive.dom.test.tsx @@ -47,6 +47,15 @@ afterEach(() => { const bodyRows = () => host!.querySelectorAll("tbody tr").length; +// Waits for the page to grow to `n` rows, one background step at a time: each step must come, but on +// a busy machine all of them together can outlast one wait's budget (seen at 190 of 200). +async function grownTo(n: number) { + for (let had = bodyRows(); had < n; had = bodyRows()) { + await waitFor(() => expect(bodyRows()).toBeGreaterThan(had)); + } + expect(bodyRows()).toBe(n); +} + // Mounts synchronously, so what the DOM holds right after is the first render alone. function mount(rows: Row[], columns: Column[], narrowedBy = "") { host = document.createElement("div"); @@ -68,7 +77,7 @@ describe("DataTable — a long page mounts in steps", () => { it("paints its first rows at once and the rest after", async () => { mount(many(200), plain); expect(bodyRows()).toBe(FIRST_ROWS); - await waitFor(() => expect(bodyRows()).toBe(200)); + await grownTo(200); }); it("does not re-render the rows it already holds as it grows", async () => { @@ -84,21 +93,21 @@ describe("DataTable — a long page mounts in steps", () => { }, ]; mount(many(200), counted); - await waitFor(() => expect(bodyRows()).toBe(200)); + await grownTo(200); // The first row was drawn by the first render, not again by each step after it. expect(renders.get("r0")).toBe(1); }); it("starts over from the first rows when the caller narrows them, not on new data", async () => { const draw = mount(many(200), plain, ""); - await waitFor(() => expect(bodyRows()).toBe(200)); + await grownTo(200); // New data for the same view (a refetch): the page keeps what it shows. draw(many(200), ""); expect(bodyRows()).toBe(200); // A search the caller applied: the urgent render is small again. draw(many(150, "s"), "s"); expect(bodyRows()).toBe(FIRST_ROWS); - await waitFor(() => expect(bodyRows()).toBe(150)); + await grownTo(150); }); // Channels turns the page to the row a priority pick moved and focuses it in the commit diff --git a/frontend/src/lib/accountPinnedStores.dom.test.ts b/frontend/src/lib/accountPinnedStores.dom.test.ts index 7f69c363..25160abc 100644 --- a/frontend/src/lib/accountPinnedStores.dom.test.ts +++ b/frontend/src/lib/accountPinnedStores.dom.test.ts @@ -33,13 +33,18 @@ beforeAll(async () => { await import("./api/core"); }, 60_000); +// Warm or not, each case still loads and evaluates the modules it imports afresh after its module +// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's +// workers share a busy machine. +const CASE_BUDGET = 30_000; + beforeEach(() => { localStorage.clear(); sessionStorage.clear(); vi.resetModules(); // both stores load at import: each test needs a fresh one }); -describe("stores read at import, in a tab not yet pinned", () => { +describe("stores read at import, in a tab not yet pinned", { timeout: CASE_BUDGET }, () => { it("keep the account's notification history and settings once the tab is pinned", async () => { localStorage.setItem("siftlode.notifications.5", JSON.stringify([entry(7, "older")])); localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 })); @@ -65,7 +70,7 @@ describe("stores read at import, in a tab not yet pinned", () => { }); }); -describe("a pinned tab pinned to another account", () => { +describe("a pinned tab pinned to another account", { timeout: CASE_BUDGET }, () => { it("leaves the previous account's notices with that account", async () => { // BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as. sessionStorage.setItem("siftlode.activeAccount", "4"); diff --git a/frontend/src/lib/api/facade.test.ts b/frontend/src/lib/api/facade.test.ts index 3a2a77a1..c125c2e1 100644 --- a/frontend/src/lib/api/facade.test.ts +++ b/frontend/src/lib/api/facade.test.ts @@ -71,7 +71,9 @@ beforeAll(async () => { slices[name] = slice; } api = ((await import("../api")) as { api: Slice }).api; -}); + // The imports above transform the whole api graph and i18n with every locale: under a ~0.6 s idle, + // but past the 10 s hook budget when the full suite's workers share a busy machine. +}, 60_000); describe("api façade is a collision-free spread of domain slices", () => { it("defines no method name in more than one slice (object spread would silently drop one)", () => { diff --git a/frontend/src/lib/buttonPrimitive.contract.test.ts b/frontend/src/lib/buttonPrimitive.contract.test.ts index 7d5f69b3..6dd8a627 100644 --- a/frontend/src/lib/buttonPrimitive.contract.test.ts +++ b/frontend/src/lib/buttonPrimitive.contract.test.ts @@ -13,19 +13,25 @@ async function hits(source: string, filePath = "src/components/__probe.tsx"): Pr } describe("a hand-rolled button", () => { - it("is refused in a feature component, the lab and lib, raw or focus-safe", async () => { - const raw = 'export const A = () => ;\n'; - expect(await hits(raw)).toBe(1); - expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1); - expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1); - // …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too. - expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1); - expect( - await hits( - 'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => x;\n', - ), - ).toBe(1); - }); + // Each new path's first lint makes the type-aware project service load it (0.4–1 s apiece, a repeat + // ~0): this case lints four, ~1.4 s idle, past 5 s on a busy machine. + it( + "is refused in a feature component, the lab and lib, raw or focus-safe", + { timeout: 30_000 }, + async () => { + const raw = 'export const A = () => ;\n'; + expect(await hits(raw)).toBe(1); + expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1); + expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1); + // …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too. + expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1); + expect( + await hits( + 'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => x;\n', + ), + ).toBe(1); + }, + ); it("is allowed only in a control primitive, and the primitives pass everywhere", async () => { for (const file of ["IconButton.tsx", "SegmentedToggle.tsx", "AnchoredPopover.tsx"]) diff --git a/frontend/src/lib/diagnostics.dom.test.ts b/frontend/src/lib/diagnostics.dom.test.ts index 5491ff46..5eece3f9 100644 --- a/frontend/src/lib/diagnostics.dom.test.ts +++ b/frontend/src/lib/diagnostics.dom.test.ts @@ -33,6 +33,11 @@ beforeAll(async () => { await import("./api"); }, 60_000); +// Warm or not, each case still loads and evaluates the modules it imports afresh after its module +// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's +// workers share a busy machine. +const CASE_BUDGET = 30_000; + beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); @@ -69,7 +74,7 @@ const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({ const codes = (batch: unknown[] | undefined) => (batch ?? []).map((e) => (e as { event: string }).event); -describe("diagnostics level", () => { +describe("diagnostics level", { timeout: CASE_BUDGET }, () => { it("the base level at boot sets INFO, streams nothing and logs nothing", async () => { const d = await fresh(); d.applyDiagnostics( diff --git a/frontend/src/lib/diagnostics.session.dom.test.ts b/frontend/src/lib/diagnostics.session.dom.test.ts index 25aeae78..83b4d0b9 100644 --- a/frontend/src/lib/diagnostics.session.dom.test.ts +++ b/frontend/src/lib/diagnostics.session.dom.test.ts @@ -54,6 +54,11 @@ beforeAll(async () => { }, 60_000); let current: typeof import("./diagnostics") | null = null; +// Warm or not, each case still loads and evaluates the modules it imports afresh after its module +// reset: tens of ms idle, but the same reload put sibling suites past 5 s when the full suite's +// workers share a busy machine. +const CASE_BUDGET = 30_000; + beforeEach(() => { vi.useFakeTimers(); vi.resetModules(); @@ -73,7 +78,7 @@ async function fresh() { const flat = () => sent.flat(); -describe("a debug session applied to the tab", () => { +describe("a debug session applied to the tab", { timeout: CASE_BUDGET }, () => { it("raises the base level to its own, and tags and streams what it keeps", async () => { const d = await fresh(); d.applyDiagnostics(BASE, recording("DEBUG"), () => {}); diff --git a/frontend/src/lib/inputPrimitive.contract.test.ts b/frontend/src/lib/inputPrimitive.contract.test.ts index dfe72bdd..90978377 100644 --- a/frontend/src/lib/inputPrimitive.contract.test.ts +++ b/frontend/src/lib/inputPrimitive.contract.test.ts @@ -14,16 +14,22 @@ async function hits(source: string, filePath = "src/components/__probe.tsx"): Pr } describe("a hand-rolled field", () => { - it("is refused anywhere outside the field primitives, text box or native control", async () => { - const text = 'export const A = () => ;\n'; - expect(await hits(text)).toBe(1); - expect(await hits(text, "src/design-lab/__probe.tsx")).toBe(1); - // …and in a ui/ composite: the pager's page box is an Input too. - expect(await hits(text, "src/components/ui/Pager.tsx")).toBe(1); - expect(await hits("export const B = () =>