Compare commits
19
Commits
v0.107.0
...
4b1f6d7ec2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b1f6d7ec2 | ||
|
|
51bf780100 | ||
|
|
3ada34073b | ||
|
|
d74f2a9ca1 | ||
|
|
a8dddc8c24 | ||
|
|
460bdec406 | ||
|
|
66181d1394 | ||
|
|
0a1fef28ac | ||
|
|
fe572d9207 | ||
|
|
c8247c0dde | ||
|
|
722c37a6d0 | ||
|
|
5ba5679239 | ||
|
|
926e5e9899 | ||
|
|
9251918e08 | ||
|
|
2b6d968833 | ||
|
|
95e6b2c3d9 | ||
|
|
80c55fbf1b | ||
|
|
011f269152 | ||
|
|
dcb244dd04 |
@@ -7,6 +7,28 @@ 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).
|
||||
|
||||
## 0.107.3 — 2026-10-17
|
||||
|
||||
### New
|
||||
|
||||
- Development: the end-to-end suite runs the WCAG A and AA accessibility checks over every page and the player, and every shell script is linted in the gate.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Readability: muted text in the light themes is a shade darker, and the item count, the labels and the selected Files folder sit on a solid tint, so text on the frosted-glass bars stays readable (4.5:1) in every scheme.
|
||||
|
||||
## 0.107.2 — 2026-10-17
|
||||
|
||||
### New
|
||||
|
||||
- Development: the gate script can list its checks (scripts/check.sh --list) and run just one (--only), and the backend tests run on every CPU core, each worker with its own database.
|
||||
|
||||
## 0.107.1 — 2026-10-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- Downloads: a failed download's explanation now says to press Retry, instead of pointing to a download list that no longer exists.
|
||||
|
||||
## 0.107.0 — 2026-10-15
|
||||
|
||||
### New
|
||||
|
||||
@@ -12,7 +12,7 @@ router-free navigation provider.
|
||||
|
||||
## The gate — never merge or ship red
|
||||
|
||||
The local gate is **ruff (`check` + `format --check`) + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
|
||||
The local gate is **ruff (`check` + `format --check`) + shellcheck + basedpyright (backend) + the lock check + typecheck (3 tsc projects) +
|
||||
eslint + prettier --check + knip + vitest + vite build + pytest + pip-audit + npm audit**. It runs
|
||||
before every `release` merges and on every `publish`, which **refuses to build on failure — there is deliberately no `--skip`
|
||||
flag**; a release also requires the Playwright suite, and the image must pass a Trivy scan before it is pushed. Run it and get it
|
||||
@@ -55,7 +55,8 @@ how it gets built. Concretely:
|
||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||
arguments, push back or propose an alternative — don't comply blindly.
|
||||
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||
bugs; do a real visual check of any UI change, and run DESIGN.md's four-step Conformance check.
|
||||
bugs; do a real visual check of any UI change, and run DESIGN.md's Conformance check (a copy-only
|
||||
change runs its step 4 alone: every state the new text renders in, in both languages).
|
||||
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
||||
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||
|
||||
@@ -101,7 +102,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
|
||||
| FE floating or full-screen layer | `ui/Overlay` (portals to `<body>`) or the popover engine (`AnchoredPopover` portals itself); stacking by the z-index scale (`z-menu` … `z-popover`, docs/DESIGN.md "Layers") | one rendered in place inside a virtualized row or a masked/transformed scroller — clipped or painted over; a magic `z-[n]` |
|
||||
| FE player on a native media element | `lib/mediaSession` (`useMediaSession`) + `lib/mediaKeys` + `lib/resume` (`useResume`), the element held as STATE | a private `navigator.mediaSession` / `keydown` / position-saving effect per player |
|
||||
| FE diagnostics | `lib/log.ts` (`log.info("event.code", msg, attrs)`); a filter change → `lib/filterLog.ts`, a player → `lib/mediaLog.ts` | `console.*` (lint-enforced); a value only a higher level may carry, logged unguarded |
|
||||
| FE state that MIRRORS a prop/query | derive it, or hold a draft (`draft ?? server`) | a copy an effect re-syncs — that paints the stale value first |
|
||||
@@ -114,7 +116,8 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
Not repeated here, because a lint rule refuses the wrong one and its message names the right one:
|
||||
buttons (`ui/Button`, `IconButton`, `Pressable`, `Chip`, `DragHandle`), text fields and native
|
||||
controls (`ui/Input`, `Checkbox`, `Slider`, …), glass surfaces (`SURFACE.*`), a native `<select>`
|
||||
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
|
||||
(`SegmentedToggle` / `FilterSelect`), a picture (`ui/RemoteImage`, never a bare `<img>`), a box or font size (the spacing scale or rem,
|
||||
never a px-frozen `w-[Npx]` / `text-[Npx]`: the text-size slider scales the root font), and network calls (`req` / `uploadReq` / `beacon`, `lib/http.ts`).
|
||||
|
||||
## House rules that bite (learned from real bugs)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
+6
-1
@@ -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<N>`,
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <copy>` 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("<ENV VAR>")` yourself before the app is imported. The variable may hold:
|
||||
- a Postgres URL (`postgresql://…/name`, `postgresql+psycopg://…`): the worker gets `name_gw<N>`, 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<N>.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}"
|
||||
+10
-1
@@ -44,6 +44,10 @@ runs this check before it is called done (E28 S3g):
|
||||
A rule that holds is not the same as a screen that works: the person page passed every rule above with
|
||||
half its panel empty.
|
||||
|
||||
A **copy-only** change — translated text and nothing else: no markup, class, component or logic — runs step 4
|
||||
alone: a screenshot of every state the changed text renders in, in Hungarian and in English (Hungarian runs about
|
||||
a fifth longer, so it is the one that wraps or clips). The key parity is the gate's.
|
||||
|
||||
## The design lab
|
||||
|
||||
`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
|
||||
@@ -126,7 +130,10 @@ style guide.
|
||||
to name one of the two. Black and white are the media scrims and their text. Five schemes × dark
|
||||
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
|
||||
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
|
||||
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
|
||||
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode;
|
||||
`--muted` keeps ≥ 6:1 on the bare `--bg`, because it mostly sits on glass, which costs up to ~1.5 of the ratio.
|
||||
A tonal fill under text (a badge, the item count, the current rail row) is an opaque `bg-tint*` (its colour
|
||||
mixed into `--surface`), never a translucent `bg-fg/8`: the glass and what scrolls under it show through.
|
||||
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
|
||||
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
|
||||
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
|
||||
@@ -783,3 +790,5 @@ of a full-width panel and nothing on the right.
|
||||
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
|
||||
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
||||
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
|
||||
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
|
||||
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
|
||||
|
||||
+13
-4
@@ -44,12 +44,11 @@ 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/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only
|
||||
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"
|
||||
```
|
||||
|
||||
It runs every lane even after one fails and names each red one at the end. `lint-changed.sh` is a
|
||||
convenience, not the gate; with `--hook` it doubles as a Claude Code Stop hook (silent when clean,
|
||||
the findings handed back otherwise).
|
||||
It runs every lane even after one fails and names each red one at the end.
|
||||
|
||||
The codebase's health numbers — size, complexity, test ratio, gate weight, comment noise — come from
|
||||
`python scripts/audit_metrics.py` (seconds; it reports, it never fails). `--speed` adds the speed
|
||||
@@ -85,6 +84,13 @@ docker compose -f backend/docker-compose.test.yml run --rm --no-deps test \
|
||||
pip-audit -r requirements.lock --no-deps --disable-pip
|
||||
```
|
||||
|
||||
**Shell scripts**
|
||||
|
||||
```bash
|
||||
# every tracked *.sh at shellcheck's default bar (info included); the version is pinned in scripts/check.sh
|
||||
git ls-files -z -- '*.sh' | xargs -0 shellcheck
|
||||
```
|
||||
|
||||
A missing database or ffmpeg is a hard error, not a skip — so the gate can't print green while the
|
||||
DB-backed tests were quietly not run (`SIFTLODE_TEST_REQUIRE_DB=0` is the explicit "pure-logic only"
|
||||
run for a machine without Postgres). Tests that need a database take the `db` fixture
|
||||
@@ -107,6 +113,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<N>`, 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).
|
||||
|
||||
@@ -45,6 +45,11 @@ login-free `/watch/<token>` share link).
|
||||
PW_BASE_URL=http://localhost:5173 siftlode e2e
|
||||
```
|
||||
|
||||
**Accessibility** — `a11y.spec.ts` runs axe-core's WCAG 2.x A + AA rules (`@axe-core/playwright`)
|
||||
over every rail page, every admin page and the player, in `chromium` only (the rules read the DOM,
|
||||
which the engines build alike). It does not judge colour contrast: axe files text on a gradient —
|
||||
every glass surface — as "incomplete", not as a pass or a hit.
|
||||
|
||||
## Account & fixture
|
||||
|
||||
Tests run as a dedicated **admin** account `e2e@siftlode.local` (never a real account;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoPage, openApp, openFirstVideo } from "./helpers";
|
||||
|
||||
// Accessibility, the machine-checkable half: axe-core's WCAG 2.x A + AA rules over every page the
|
||||
// owner reaches and the player. A hit fails with the rule, its impact and the elements.
|
||||
//
|
||||
// What it does NOT cover — colour contrast, mostly: axe cannot compute a contrast over a gradient,
|
||||
// and the glass surfaces are gradients, so it files most text as "incomplete" instead of judging it
|
||||
// (measured 2026-10-17: 110 nodes on the feed, 9-32 on the other pages). A green here says nothing
|
||||
// about contrast; that stays with the visual check.
|
||||
//
|
||||
// Chromium only (playwright.config.ts leaves it out of the other projects).
|
||||
const TAGS = ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
|
||||
|
||||
/** Wait until nothing is loading: no QueryState spinner and no skeleton bone left. */
|
||||
async function settled(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page.locator('[role="status"] .animate-spin, [aria-hidden="true"].animate-pulse'),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function violations(page: Page): Promise<string[]> {
|
||||
const result = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||||
return result.violations.map((v) => {
|
||||
const shown = v.nodes.slice(0, 5).map((n) => n.target.join(" "));
|
||||
const more = v.nodes.length > 5 ? ` (+${v.nodes.length - 5})` : "";
|
||||
return `${v.id} (${v.impact ?? "?"}): ${v.help} — ${shown.join(" | ")}${more}`;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("accessibility (axe)", () => {
|
||||
const RAIL = ["feed", "channels", "playlists", "files", "stats", "settings"];
|
||||
const ADMIN = ["scheduler", "config", "users", "audit"];
|
||||
|
||||
for (const id of RAIL) {
|
||||
test(`page: ${id}`, async ({ page }) => {
|
||||
await openApp(page);
|
||||
await gotoPage(page, id);
|
||||
await settled(page);
|
||||
expect(await violations(page)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of ADMIN) {
|
||||
test(`admin page: ${id}`, async ({ page }) => {
|
||||
await openApp(page);
|
||||
await page.getByTestId("nav-admin").click();
|
||||
await page.getByTestId(`nav-admin-${id}`).click();
|
||||
await expect(page.getByTestId("nav-admin")).toHaveAttribute("aria-current", "page");
|
||||
await settled(page);
|
||||
expect(await violations(page)).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test("player", async ({ page }) => {
|
||||
// hermetic: the YouTube iframe is not loaded
|
||||
await page.route(/youtube\.com/, (route) => route.abort());
|
||||
await openApp(page);
|
||||
await openFirstVideo(page);
|
||||
await settled(page);
|
||||
expect(await violations(page)).toEqual([]);
|
||||
});
|
||||
});
|
||||
Generated
+27
-3
@@ -23,6 +23,7 @@
|
||||
"react-i18next": "^15.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
@@ -91,6 +92,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@axe-core/playwright": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
|
||||
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"axe-core": "~4.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-core": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
@@ -3631,6 +3645,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
|
||||
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-react-compiler": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
|
||||
@@ -6163,9 +6187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"react-i18next": "^15.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@babel/core": "7.29.7",
|
||||
"@eslint-community/eslint-plugin-eslint-comments": "4.8.1",
|
||||
"@eslint/js": "9.39.1",
|
||||
|
||||
@@ -17,6 +17,9 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
|
||||
const STORAGE_STATE = "e2e/.auth/state.json";
|
||||
// The axe scan reads the DOM and the accessibility tree, which the engines build alike: one engine
|
||||
// answers for all, so the other projects leave it out instead of tripling its time.
|
||||
const CHROMIUM_ONLY = /a11y\.spec\.ts/;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -54,11 +57,13 @@ export default defineConfig({
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"], storageState: STORAGE_STATE },
|
||||
testIgnore: CHROMIUM_ONLY,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
name: "ipad",
|
||||
use: { ...devices["iPad (gen 7) landscape"], storageState: STORAGE_STATE },
|
||||
testIgnore: CHROMIUM_ONLY,
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
// The speed baseline (e2e/speed.measure.ts): readings, not tests. Only when PW_SPEED is set —
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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> = {}): 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)];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ function Entry({
|
||||
onClick={() => setView(view)}
|
||||
className={clsx(
|
||||
"flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm",
|
||||
active ? "bg-accent/12 font-medium text-accent" : "text-fg",
|
||||
active ? "bg-tint-current font-medium text-accent" : "text-fg",
|
||||
)}
|
||||
>
|
||||
<Icon className={clsx("size-4 shrink-0", !active && "text-muted")} />
|
||||
|
||||
@@ -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 <body> 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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<typeof import("react-i18next")>();
|
||||
@@ -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);
|
||||
|
||||
@@ -7,10 +7,12 @@ import type { ReactNode } from "react";
|
||||
// at 8%, so it reads on the dark schemes and on white alike; `onDark` is the always-dark player's. A
|
||||
// `tone` says which kind of label it is (a role, a state to notice): its own colour at 6%, the most
|
||||
// that keeps its text at 4.5:1 on light (theme.contract.test.ts measures all three in every scheme).
|
||||
// Each tint is mixed into the surface (`bg-tint*`), opaque: a translucent one lets the glass and what
|
||||
// scrolls under it through, and the text's contrast goes with them.
|
||||
const TONES = {
|
||||
neutral: "bg-fg/8 text-muted",
|
||||
accent: "bg-accent/6 text-accent",
|
||||
warn: "bg-warn/6 text-warn",
|
||||
neutral: "bg-tint text-muted",
|
||||
accent: "bg-tint-accent text-accent",
|
||||
warn: "bg-tint-warn text-warn",
|
||||
} as const;
|
||||
|
||||
export function Badge({
|
||||
|
||||
@@ -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<Row>[], 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
|
||||
|
||||
@@ -8,7 +8,8 @@ import { formatNumber } from "../../lib/format";
|
||||
const OVERFLOW_AT = 1_000_000;
|
||||
|
||||
// A compact, FIXED-WIDTH count pill for a toolbar: the filtered item count at the toolbar's 28, flat
|
||||
// and tonal like a `Badge` — no edge, because it does nothing (edged = pressable, flat = just
|
||||
// and tonal like a `Badge` (the same opaque `bg-tint`: the toolbar is glass over scrolling thumbnails)
|
||||
// — no edge, because it does nothing (edged = pressable, flat = just
|
||||
// says; it used to wear the filter dropdowns' glass pill and read as one more). The fixed width is the point — a
|
||||
// changing number must never shove the controls beside it, so the box is sized to the widest value
|
||||
// ("999,999 items") and the content is centred within it. A count of a million or more collapses to
|
||||
@@ -46,7 +47,7 @@ export default function ItemCount({
|
||||
failed ? t("common.countFailed") : count == null ? undefined : formatNumber(count, lang)
|
||||
}
|
||||
aria-busy={stale || undefined}
|
||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-fg/8 px-3 text-sm whitespace-nowrap text-muted"
|
||||
className="inline-flex h-7 min-w-[9rem] shrink-0 items-center justify-center gap-1.5 rounded-full bg-tint px-3 text-sm whitespace-nowrap text-muted"
|
||||
>
|
||||
<Layers className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className={clsx("tabular-nums transition-opacity", stale && "opacity-60")}>
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
[
|
||||
{
|
||||
"version": "0.107.3",
|
||||
"date": "2026-10-17",
|
||||
"new": [
|
||||
"Development: the end-to-end suite runs the WCAG A and AA accessibility checks over every page and the player, and every shell script is linted in the gate."
|
||||
],
|
||||
"fixed": [
|
||||
"Readability: muted text in the light themes is a shade darker, and the item count, the labels and the selected Files folder sit on a solid tint, so text on the frosted-glass bars stays readable (4.5:1) in every scheme."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.107.2",
|
||||
"date": "2026-10-17",
|
||||
"new": [
|
||||
"Development: the gate script can list its checks (scripts/check.sh --list) and run just one (--only), and the backend tests run on every CPU core, each worker with its own database."
|
||||
],
|
||||
"fixed": []
|
||||
},
|
||||
{
|
||||
"version": "0.107.1",
|
||||
"date": "2026-10-16",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"Downloads: a failed download's explanation now says to press Retry, instead of pointing to a download list that no longer exists."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.107.0",
|
||||
"date": "2026-10-15",
|
||||
|
||||
@@ -1,4 +1,30 @@
|
||||
[
|
||||
{
|
||||
"version": "0.107.3",
|
||||
"date": "2026-10-17",
|
||||
"new": [
|
||||
"Fejlesztés: a végponttól végpontig tartó tesztek minden oldalon és a lejátszóban lefuttatják a WCAG A és AA akadálymentességi ellenőrzéseit, és a gate minden shell-szkriptet ellenőriz."
|
||||
],
|
||||
"fixed": [
|
||||
"Olvashatóság: a halvány szöveg a világos témákban egy árnyalattal sötétebb, a darabszám, a címkék és a Fájlok kijelölt mappája pedig tömör árnyalaton ül, így az üvegszerű sávokon is olvasható (4,5:1) minden színsémában."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.107.2",
|
||||
"date": "2026-10-17",
|
||||
"new": [
|
||||
"Fejlesztés: a gate-szkript kilistázza az ellenőrzéseit (scripts/check.sh --list), és egyet is futtathat közülük (--only); a backend tesztjei minden processzormagon futnak, mindegyik saját adatbázissal."
|
||||
],
|
||||
"fixed": []
|
||||
},
|
||||
{
|
||||
"version": "0.107.1",
|
||||
"date": "2026-10-16",
|
||||
"new": [],
|
||||
"fixed": [
|
||||
"Letöltés: egy sikertelen letöltés magyarázata most az Újra gombot mondja, nem egy már nem létező letöltési listát."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.107.0",
|
||||
"date": "2026-10-15",
|
||||
|
||||
@@ -316,11 +316,11 @@
|
||||
"facebook": {
|
||||
"login_wall": {
|
||||
"short": "Facebook sign-in required",
|
||||
"body": "Facebook only shows this to signed-in users (e.g. 18+ content or a closed group). Add your Facebook cookies under Settings → Account → Download authentication, then retry the download from the list."
|
||||
"body": "Facebook only shows this to signed-in users (e.g. 18+ content or a closed group). Add your Facebook cookies under Settings → Account → Download authentication, then press Retry."
|
||||
},
|
||||
"cookie_rejected": {
|
||||
"short": "Facebook didn't accept the cookies",
|
||||
"body": "The cookies have probably expired, or you signed out in that browser. Export them again, paste them in under Settings → Account → Download authentication, then retry the download from the list. If it still fails, the content has been removed or isn't visible to you."
|
||||
"body": "The cookies have probably expired, or you signed out in that browser. Export them again, paste them in under Settings → Account → Download authentication, then press Retry. If it still fails, the content has been removed or isn't visible to you."
|
||||
},
|
||||
"group_post_video": {
|
||||
"short": "No video found in this post",
|
||||
@@ -330,15 +330,15 @@
|
||||
"youtube": {
|
||||
"bot_wall": {
|
||||
"short": "YouTube sign-in required",
|
||||
"body": "YouTube only serves this video to a signed-in viewer — it asks the downloader to confirm it isn't a bot. Other YouTube videos are usually unaffected. Add your YouTube cookies under Settings → Account → Download authentication, then retry the download from the list."
|
||||
"body": "YouTube only serves this video to a signed-in viewer — it asks the downloader to confirm it isn't a bot. Other YouTube videos are usually unaffected. Add your YouTube cookies under Settings → Account → Download authentication, then press Retry."
|
||||
},
|
||||
"cookie_rejected": {
|
||||
"short": "YouTube didn't accept the cookies",
|
||||
"body": "YouTube rotates exported cookies as a security measure, so yours have probably expired. Export them again from a private browsing window, close that window without browsing on, paste them in under Settings → Account → Download authentication, then retry the download from the list."
|
||||
"body": "YouTube rotates exported cookies as a security measure, so yours have probably expired. Export them again from a private browsing window, close that window without browsing on, paste them in under Settings → Account → Download authentication, then press Retry."
|
||||
},
|
||||
"media_forbidden": {
|
||||
"short": "YouTube refused the video stream",
|
||||
"body": "YouTube found the video but refused to send the file to a signed-out download. It comes and goes, so a retry from the list is worth one try — and adding your YouTube cookies under Settings → Account → Download authentication is what gets a stubborn one through."
|
||||
"body": "YouTube found the video but refused to send the file to a signed-out download. It comes and goes, so pressing Retry once is worth it — and adding your YouTube cookies under Settings → Account → Download authentication is what gets a stubborn one through."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,11 +316,11 @@
|
||||
"facebook": {
|
||||
"login_wall": {
|
||||
"short": "Facebook-bejelentkezés szükséges",
|
||||
"body": "A Facebook ezt csak bejelentkezve mutatja (pl. 18+ tartalom vagy zárt csoport). Illeszd be a Facebook-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést."
|
||||
"body": "A Facebook ezt csak bejelentkezve mutatja (pl. 18+ tartalom vagy zárt csoport). Illeszd be a Facebook-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot."
|
||||
},
|
||||
"cookie_rejected": {
|
||||
"short": "A Facebook nem fogadta el a cookie-kat",
|
||||
"body": "A cookie-k valószínűleg lejártak, vagy kijelentkeztél abban a böngészőben. Exportáld újra őket, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést. Ha így sem megy, a tartalmat törölték, vagy nem látható számodra."
|
||||
"body": "A cookie-k valószínűleg lejártak, vagy kijelentkeztél abban a böngészőben. Exportáld újra őket, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot. Ha így sem megy, a tartalmat törölték, vagy nem látható számodra."
|
||||
},
|
||||
"group_post_video": {
|
||||
"short": "Ebben a posztban nem található videó",
|
||||
@@ -330,15 +330,15 @@
|
||||
"youtube": {
|
||||
"bot_wall": {
|
||||
"short": "YouTube-bejelentkezés szükséges",
|
||||
"body": "A YouTube ezt a videót csak bejelentkezett nézőnek adja ki — a letöltőprogramtól azt kéri, igazolja, hogy nem robot. A többi YouTube-videót ez általában nem érinti. Illeszd be a YouTube-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést."
|
||||
"body": "A YouTube ezt a videót csak bejelentkezett nézőnek adja ki — a letöltőprogramtól azt kéri, igazolja, hogy nem robot. A többi YouTube-videót ez általában nem érinti. Illeszd be a YouTube-cookie-kat a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot."
|
||||
},
|
||||
"cookie_rejected": {
|
||||
"short": "A YouTube nem fogadta el a cookie-kat",
|
||||
"body": "A YouTube biztonsági okból lecseréli az exportált cookie-kat, ezért a tiéid valószínűleg lejártak. Exportáld őket újra egy privát böngészőablakból, majd zárd be azt az ablakot további böngészés nélkül, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd a listában indítsd újra a letöltést."
|
||||
"body": "A YouTube biztonsági okból lecseréli az exportált cookie-kat, ezért a tiéid valószínűleg lejártak. Exportáld őket újra egy privát böngészőablakból, majd zárd be azt az ablakot további böngészés nélkül, illeszd be a Beállítások → Fiók → Letöltés-hitelesítés alatt, majd nyomd meg az Újra gombot."
|
||||
},
|
||||
"media_forbidden": {
|
||||
"short": "A YouTube nem adta ki a videófájlt",
|
||||
"body": "A YouTube visszaadta a videó adatait, de a fájlt már nem adta ki bejelentkezés nélkül. Ez hol van, hol nincs, ezért érdemes újrapróbálni a listából — a makacs eseteken pedig a YouTube-cookie-k segítenek, a Beállítások → Fiók → Letöltés-hitelesítés alatt."
|
||||
"body": "A YouTube visszaadta a videó adatait, de a fájlt már nem adta ki bejelentkezés nélkül. Ez hol van, hol nincs, ezért érdemes egyszer megnyomni az Újra gombot — a makacs eseteken pedig a YouTube-cookie-k segítenek, a Beállítások → Fiók → Letöltés-hitelesítés alatt."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-5
@@ -51,6 +51,13 @@
|
||||
--color-live-fg: var(--live-fg);
|
||||
--color-media-chrome: var(--media-chrome);
|
||||
--color-media-mark: var(--media-mark);
|
||||
/* A tonal fill under text (a Badge, the item count, the current rail row): its colour mixed into the
|
||||
surface, OPAQUE — a `bg-fg/8` lets the glass and whatever scrolls under it through, which pixel-measured
|
||||
the item count down to 3.8:1. theme.contract.test.ts measures each one's text on it. */
|
||||
--color-tint: color-mix(in srgb, var(--fg) 8%, var(--surface));
|
||||
--color-tint-accent: color-mix(in srgb, var(--accent) 6%, var(--surface));
|
||||
--color-tint-warn: color-mix(in srgb, var(--warn) 6%, var(--surface));
|
||||
--color-tint-current: color-mix(in srgb, var(--accent) 12%, var(--surface));
|
||||
|
||||
--font-sans: Inter, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
|
||||
@@ -1032,7 +1039,7 @@ html[data-scheme="midnight"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #dde3f0;
|
||||
--fg: #1a2238;
|
||||
--muted: #5b6685;
|
||||
--muted: #515c79;
|
||||
--accent: #3b5bdb;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1054,7 +1061,7 @@ html[data-scheme="forest"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #d6e7e0;
|
||||
--fg: #102a22;
|
||||
--muted: #4d6b60;
|
||||
--muted: #446157;
|
||||
--accent: #0e7490;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1076,7 +1083,7 @@ html[data-scheme="slate"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #e3e6eb;
|
||||
--fg: #1c1f26;
|
||||
--muted: #5d636e;
|
||||
--muted: #535963;
|
||||
--accent: #a21caf;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1100,7 +1107,7 @@ html[data-scheme="matrix"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #dfe3d8;
|
||||
--fg: #161a14;
|
||||
--muted: #5a6352;
|
||||
--muted: #505849;
|
||||
--accent: #556b2f;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
@@ -1126,7 +1133,7 @@ html[data-scheme="fjord"][data-theme="light"] {
|
||||
--card: #ffffff;
|
||||
--border: #c9d6e3;
|
||||
--fg: #152232;
|
||||
--muted: #4f6278;
|
||||
--muted: #46586e;
|
||||
--accent: #256b8f;
|
||||
--accent-fg: #ffffff;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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 = () => <button className="px-3">x</button>;\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 = () => <FocusSafeButton>x</FocusSafeButton>;\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 = () => <button className="px-3">x</button>;\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 = () => <FocusSafeButton>x</FocusSafeButton>;\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"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"), () => {});
|
||||
|
||||
@@ -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([]);
|
||||
@@ -552,7 +584,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<N>`), 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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -1298,30 +1299,59 @@
|
||||
"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 \"<lane>\"]' >&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=\"\"",
|
||||
"timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it must add to $timings)",
|
||||
" 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)\" \"${only:+only}${only:-$mode}\" \"$1\" \"$took\" \"$2\" \\",
|
||||
" \"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)\" >>\"$GATE_TIMES_FILE\"",
|
||||
" fi",
|
||||
" return 0",
|
||||
"}",
|
||||
"lane() { # lane <name> <command…> — run it, remember a failure, never stop",
|
||||
" local name=\"$1\"",
|
||||
" 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…\"",
|
||||
" \"$@\" || failed+=(\"$name\")",
|
||||
" \"$@\" || { failed+=(\"$name\"); result=red; }",
|
||||
" timed \"$name\" \"$result\" \"$t0\"",
|
||||
" echo \"<< $name: $took s\"",
|
||||
" return 0",
|
||||
" fi",
|
||||
" local out",
|
||||
" out=\"$(mktemp)\"",
|
||||
" if \"$@\" >\"$out\" 2>&1; then",
|
||||
" echo \">> $name: ok\"",
|
||||
" timed \"$name\" ok \"$t0\"",
|
||||
" echo \">> $name: ok ($took s)\"",
|
||||
" else",
|
||||
" failed+=(\"$name\")",
|
||||
" echo \">> $name: FAILED\"",
|
||||
" timed \"$name\" red \"$t0\"",
|
||||
" echo \">> $name: FAILED ($took s)\"",
|
||||
" brief_excerpt \"$out\"",
|
||||
" fi",
|
||||
" rm -f \"$out\"",
|
||||
@@ -1347,6 +1377,16 @@
|
||||
"}",
|
||||
"ruff_check() { ruff_pinned && python -m ruff check --config backend/ruff.toml backend scripts; }",
|
||||
"ruff_format() { ruff_pinned && python -m ruff format --check --config backend/ruff.toml backend scripts; }",
|
||||
"SHELLCHECK_PIN=0.11.0",
|
||||
"shellcheck_lane() {",
|
||||
" local host",
|
||||
" host=\"$(shellcheck --version 2>/dev/null | sed -n 's/^version: //p')\"",
|
||||
" if [ \"$host\" != \"$SHELLCHECK_PIN\" ]; then",
|
||||
" echo \"shellcheck ${host:-missing} on the host, $SHELLCHECK_PIN pinned — https://github.com/koalaman/shellcheck/releases\" >&2",
|
||||
" return 1",
|
||||
" fi",
|
||||
" git ls-files -z -- '*.sh' | xargs -0 shellcheck",
|
||||
"}",
|
||||
"basedpyright() {",
|
||||
" local before",
|
||||
" before=\"$(git hash-object backend/.basedpyright/baseline.json)\"",
|
||||
@@ -1361,8 +1401,10 @@
|
||||
" return 1",
|
||||
"}",
|
||||
"frontend() { (cd frontend && \"$@\"); }",
|
||||
"gate_t0=\"$(ms)\"",
|
||||
"lane \"ruff\" ruff_check",
|
||||
"lane \"ruff format --check\" ruff_format",
|
||||
"lane \"shellcheck (every shell script)\" shellcheck_lane",
|
||||
"lane \"basedpyright (new errors only)\" basedpyright",
|
||||
"lane \"lock check (frontend/node_modules == package-lock.json)\" node scripts/check-node-modules.mjs",
|
||||
"lane \"tsc (src + configs + e2e)\" frontend npm run --silent typecheck",
|
||||
@@ -1374,7 +1416,7 @@
|
||||
" local rc=0",
|
||||
" docker compose -f \"$TESTCOMPOSE\" up --build --abort-on-container-exit --exit-code-from test || rc=1",
|
||||
" docker compose -f \"$TESTCOMPOSE\" down -v >/dev/null 2>&1",
|
||||
" return $rc",
|
||||
" return \"$rc\"",
|
||||
"}",
|
||||
"pip_audit() {",
|
||||
" docker compose -f \"$TESTCOMPOSE\" run --rm --no-deps test \\",
|
||||
@@ -1387,11 +1429,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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 = () => <input className="border px-2" />;\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 = () => <textarea />;\n")).toBe(1);
|
||||
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
|
||||
expect(await hits('export const D = () => <input type="range" />;\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 three, ~1 s idle, past 5 s on a busy machine.
|
||||
it(
|
||||
"is refused anywhere outside the field primitives, text box or native control",
|
||||
{ timeout: 30_000 },
|
||||
async () => {
|
||||
const text = 'export const A = () => <input className="border px-2" />;\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 = () => <textarea />;\n")).toBe(1);
|
||||
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
|
||||
expect(await hits('export const D = () => <input type="range" />;\n')).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("passes in a field primitive, for a file picker, and for the primitives themselves", async () => {
|
||||
for (const file of ["Input.tsx", "NativeControls.tsx"])
|
||||
|
||||
@@ -103,7 +103,8 @@ describe("plan codes", () => {
|
||||
expect(pattern("PLAN_CODE")).toBe(PLAN_CODES.map(js).join("|"));
|
||||
});
|
||||
|
||||
it("are not cited in the code", () => {
|
||||
// Reads every tracked and new file of the repo: ~0.6 s idle, past 5 s on a busy machine's disk and CPU.
|
||||
it("are not cited in the code", { timeout: 30_000 }, () => {
|
||||
const files = tracked();
|
||||
expect(files.length).toBeGreaterThan(1000); // a scan that reads nothing is green over nothing
|
||||
const hits = files.flatMap((f) =>
|
||||
|
||||
@@ -38,7 +38,8 @@ function files(dir: string): string[] {
|
||||
}
|
||||
|
||||
describe("removed features stay removed", () => {
|
||||
it("leaves no trace of the demo account", () => {
|
||||
// Reads every source file of the repo: ~0.4 s idle, past 5 s on a busy machine's disk and CPU.
|
||||
it("leaves no trace of the demo account", { timeout: 30_000 }, () => {
|
||||
const hits = ROOTS.flatMap(files)
|
||||
.filter((f) => !ALLOWED.has(f))
|
||||
.flatMap((f) =>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { hexContrast, hueGap, mixHex } from "./colorMath";
|
||||
import { SCHEMES } from "./theme";
|
||||
|
||||
// docs/DESIGN.md's colour rules, held in code instead of prose. Every scheme × mode in index.css:
|
||||
// - body text, muted text and accent text ≥ 4.5:1 on the page, and the accent's own text ≥ 4.5:1 on
|
||||
// an accent fill [WCAG 1.4.3];
|
||||
// - body text and accent text ≥ 4.5:1 on the page, muted text ≥ 6:1 there (the headroom glass costs),
|
||||
// and the accent's own text ≥ 4.5:1 on an accent fill [WCAG 1.4.3];
|
||||
// - a control's edge (--edge-strong, the fg mixed 55% into the bg) ≥ 3:1 on the page [WCAG 1.4.11];
|
||||
// - the accent ≥ 30° of OKLCH hue away from danger, warn and success, so a primary button never
|
||||
// reads as a status (decision B2: the old YouTube, Slate, Forest and Matrix accents all did).
|
||||
@@ -177,6 +177,22 @@ it("text on a solid accent fill uses --accent-fg", () => {
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
// The tints measured below are only what renders while the tonal fills USE them: a translucent
|
||||
// `bg-fg/8` there passes every token check and still lets the glass under a toolbar or a rail through.
|
||||
it("a tonal fill under text is an opaque tint", () => {
|
||||
const sites = [
|
||||
"components/ui/Badge.tsx",
|
||||
"components/ui/ItemCount.tsx",
|
||||
"components/FilesRail.tsx",
|
||||
];
|
||||
const offenders = sites.flatMap((f) => {
|
||||
const src = readFileSync(new URL(`../${f}`, import.meta.url), "utf8");
|
||||
const bad = [...src.matchAll(/\bbg-(?:fg|accent|warn)\/\d+/g)].map((m) => `${f}: ${m[0]}`);
|
||||
return /\bbg-tint/.test(src) ? bad : [...bad, `${f}: no bg-tint`];
|
||||
});
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
|
||||
describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
|
||||
it.each(MODES)("%s: backdrop rule and file", (mode) => {
|
||||
const file = `backdrops/${mode === "light" ? "light/" : ""}${id}.svg`;
|
||||
@@ -200,15 +216,23 @@ describe.each(SCHEMES.map((s) => s.id))("scheme %s", (id) => {
|
||||
// A score is text, on the page and on its own 15% chip (PlexInfo); the live glyph is not text.
|
||||
rating: hexContrast(s.rating!, bg),
|
||||
ratingOnChip: hexContrast(s.rating!, mixHex(s.rating!, bg, 0.15)),
|
||||
// A Badge (a role, a status, a count, F29): the neutral one's muted text on the fg at 8%, a toned
|
||||
// one's text on its own colour at 6% (ui/Badge).
|
||||
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, bg, 0.08)),
|
||||
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, bg, 0.06)),
|
||||
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, bg, 0.06)),
|
||||
// A tonal fill under text (the `tint` tokens: a Badge, the item count, the current rail row) is
|
||||
// OPAQUE — its colour mixed into --surface, so the glass and what scrolls under it never show
|
||||
// through: the neutral one's muted text on the fg at 8%, a toned one's text on its own colour at
|
||||
// 6%, the current row's accent on the accent at 12%.
|
||||
mutedOnBadge: hexContrast(t.muted!, mixHex(t.fg!, t.surface!, 0.08)),
|
||||
accentOnBadge: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.06)),
|
||||
warnOnBadge: hexContrast(s.warn!, mixHex(s.warn!, t.surface!, 0.06)),
|
||||
accentOnCurrent: hexContrast(t.accent!, mixHex(t.accent!, t.surface!, 0.12)),
|
||||
live: hexContrast(media().live!, bg),
|
||||
};
|
||||
const nonText = new Set(["edge", "live"]);
|
||||
const failing = Object.entries(measured).filter(([k, v]) => v < (nonText.has(k) ? 3 : 4.5));
|
||||
// Muted text mostly sits on GLASS (a toolbar, a tab row, a panel): the scrim, the blur and the
|
||||
// backdrop under it cost up to ~1.5 of the ratio a bare --bg gives (pixel-measured over every
|
||||
// scheme × mode × backdrop), so on the bare page it keeps that headroom over 4.5.
|
||||
const onGlass = new Set(["muted"]);
|
||||
const need = (k: string) => (nonText.has(k) ? 3 : onGlass.has(k) ? 6 : 4.5);
|
||||
const failing = Object.entries(measured).filter(([k, v]) => v < need(k));
|
||||
expect(failing.map(([k, v]) => `${k} ${v.toFixed(2)}:1`)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ beforeAll(async () => {
|
||||
await import("./useVerifyEntry");
|
||||
}, 60_000);
|
||||
|
||||
// Warm or not, each case still loads and evaluates the hook's graph afresh after its module reset:
|
||||
// ~60 ms idle, past 5 s when the full suite's workers share a busy machine — and a case cut off
|
||||
// mid-mount leaves its render behind for the next one to trip over.
|
||||
const CASE_BUDGET = 30_000;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.resetModules();
|
||||
@@ -52,7 +57,7 @@ async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
||||
return () => screen.getByTestId("outcome").textContent;
|
||||
}
|
||||
|
||||
describe("confirming an emailed verification link", () => {
|
||||
describe("confirming an emailed verification link", { timeout: CASE_BUDGET }, () => {
|
||||
it("posts the fragment token and reports the confirmed address", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
||||
|
||||
@@ -253,52 +258,56 @@ describe("confirming an emailed verification link", () => {
|
||||
// already runs the app to a URL differing only in the fragment is a same-document navigation, so
|
||||
// nothing reloads, the import-time snapshot never sees the token, and it sat unconsumed in the
|
||||
// address bar — the same silence the sprint set out to remove, wearing a different hat.
|
||||
describe("a verification link that arrives as a fragment-only navigation", () => {
|
||||
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
|
||||
const outcome = await mountAt("/", verifyEmail);
|
||||
expect(verifyEmail).not.toHaveBeenCalled();
|
||||
describe(
|
||||
"a verification link that arrives as a fragment-only navigation",
|
||||
{ timeout: CASE_BUDGET },
|
||||
() => {
|
||||
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
|
||||
const outcome = await mountAt("/", verifyEmail);
|
||||
expect(verifyEmail).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=pasted-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
|
||||
expect(outcome()).toBe("ok:pasted@example.com");
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
|
||||
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
|
||||
// token is spent, and this case is about the SECOND outcome replacing the first.
|
||||
.mockImplementationOnce(async () => {
|
||||
const { HttpError } = await import("./api/core");
|
||||
throw new HttpError(400, "verify", { code: "verify" });
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=pasted-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
const outcome = await mountAt("/#verify=first-token", verifyEmail);
|
||||
expect(outcome()).toBe("ok:first@example.com");
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=spent-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
|
||||
expect(outcome()).toBe("ok:pasted@example.com");
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
it("replaces a standing outcome rather than leaving the previous banner up", async () => {
|
||||
const verifyEmail = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ ok: true, email: "first@example.com" })
|
||||
// The server's own refusal, not a bare Error: only a 400 carrying {code:"verify"} means the
|
||||
// token is spent, and this case is about the SECOND outcome replacing the first.
|
||||
.mockImplementationOnce(async () => {
|
||||
const { HttpError } = await import("./api/core");
|
||||
throw new HttpError(400, "verify", { code: "verify" });
|
||||
});
|
||||
const outcome = await mountAt("/#verify=first-token", verifyEmail);
|
||||
expect(outcome()).toBe("ok:first@example.com");
|
||||
|
||||
it("posts a given token once even if the same fragment arrives again", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
|
||||
await mountAt("/#verify=same-token", verifyEmail);
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=spent-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=same-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
expect(outcome()).toBe("invalid:-");
|
||||
});
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
it("posts a given token once even if the same fragment arrives again", async () => {
|
||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
|
||||
await mountAt("/#verify=same-token", verifyEmail);
|
||||
|
||||
await act(async () => {
|
||||
window.history.replaceState(null, "", "/#verify=same-token");
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
});
|
||||
|
||||
expect(verifyEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -103,7 +103,8 @@ describe("the /watch entry", () => {
|
||||
expect(leaks).toEqual([]);
|
||||
});
|
||||
|
||||
it("catches a leak one import deep (the walk is not vacuous)", () => {
|
||||
// Parses every module the whole app reaches: ~1.3 s idle, past 5 s on a busy machine.
|
||||
it("catches a leak one import deep (the walk is not vacuous)", { timeout: 30_000 }, () => {
|
||||
// main.tsx is the app: walking it must find the app's i18n, and a page it only loads lazily.
|
||||
const app = reach(join(SRC, "main.tsx"));
|
||||
expect(app.has("src/i18n/index.ts")).toBe(true);
|
||||
|
||||
+76
-11
@@ -9,41 +9,80 @@
|
||||
# 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 "<lane>" 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=<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
|
||||
#
|
||||
# Each lane runs even when an earlier one failed, and the summary names every red one: a gate that
|
||||
# stops at the first failure hides the second until the first is fixed. The backend lanes run in the
|
||||
# test image (backend/Dockerfile.test): requirements.lock will not install on Windows (uvloop), and a
|
||||
# type checker or auditor without the real packages reports nonsense.
|
||||
# shellcheck disable=SC2329 # the lane functions below are called by name, through lane()
|
||||
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 "<lane>"]' >&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
|
||||
# file — one tab-separated row per lane appended to it (UTC time, mode, lane, seconds, ok|red, tree), so
|
||||
# runs can be compared. The script writes no file unless asked to.
|
||||
took=""
|
||||
timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it must add to $timings)
|
||||
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)" "${only:+only}${only:-$mode}" "$1" "$took" "$2" \
|
||||
"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
lane() { # lane <name> <command…> — run it, remember a failure, never stop
|
||||
local name="$1"
|
||||
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…"
|
||||
"$@" || failed+=("$name")
|
||||
"$@" || { failed+=("$name"); result=red; }
|
||||
timed "$name" "$result" "$t0"
|
||||
echo "<< $name: $took s"
|
||||
return 0
|
||||
fi
|
||||
local out
|
||||
out="$(mktemp)"
|
||||
if "$@" >"$out" 2>&1; then
|
||||
echo ">> $name: ok"
|
||||
timed "$name" ok "$t0"
|
||||
echo ">> $name: ok ($took s)"
|
||||
else
|
||||
failed+=("$name")
|
||||
echo ">> $name: FAILED"
|
||||
timed "$name" red "$t0"
|
||||
echo ">> $name: FAILED ($took s)"
|
||||
brief_excerpt "$out"
|
||||
fi
|
||||
rm -f "$out"
|
||||
@@ -83,6 +122,20 @@ ruff_pinned() {
|
||||
ruff_check() { ruff_pinned && python -m ruff check --config backend/ruff.toml backend scripts; }
|
||||
ruff_format() { ruff_pinned && python -m ruff format --check --config backend/ruff.toml backend scripts; }
|
||||
|
||||
# Every tracked shell script (the gate itself, backup/restore, the installer, the image's entrypoint) at
|
||||
# the linter's full default bar, info included. Host shellcheck, held to this pin like ruff: a newer one
|
||||
# adds checks, and a gate must not change under a green commit.
|
||||
SHELLCHECK_PIN=0.11.0
|
||||
shellcheck_lane() {
|
||||
local host
|
||||
host="$(shellcheck --version 2>/dev/null | sed -n 's/^version: //p')"
|
||||
if [ "$host" != "$SHELLCHECK_PIN" ]; then
|
||||
echo "shellcheck ${host:-missing} on the host, $SHELLCHECK_PIN pinned — https://github.com/koalaman/shellcheck/releases" >&2
|
||||
return 1
|
||||
fi
|
||||
git ls-files -z -- '*.sh' | xargs -0 shellcheck
|
||||
}
|
||||
|
||||
# New errors only: the pre-existing ones are in backend/.basedpyright/baseline.json. basedpyright
|
||||
# TIGHTENS that file by itself when errors go, and the tree is bind-mounted read-write, so a green run
|
||||
# can leave it rewritten: that fails here until it is committed, or the next run's stack reads stale.
|
||||
@@ -102,8 +155,10 @@ basedpyright() {
|
||||
|
||||
frontend() { (cd frontend && "$@"); }
|
||||
|
||||
gate_t0="$(ms)"
|
||||
lane "ruff" ruff_check
|
||||
lane "ruff format --check" ruff_format
|
||||
lane "shellcheck (every shell script)" shellcheck_lane
|
||||
lane "basedpyright (new errors only)" basedpyright
|
||||
# every frontend lane judges the HOST node_modules: this proves it is the tree the lock pins
|
||||
lane "lock check (frontend/node_modules == package-lock.json)" node scripts/check-node-modules.mjs
|
||||
@@ -119,7 +174,7 @@ pytest_lane() { # the full suite against a throwaway Postgres; --pull keeps the
|
||||
local rc=0
|
||||
docker compose -f "$TESTCOMPOSE" up --build --abort-on-container-exit --exit-code-from test || rc=1
|
||||
docker compose -f "$TESTCOMPOSE" down -v >/dev/null 2>&1
|
||||
return $rc
|
||||
return "$rc"
|
||||
}
|
||||
# Every pinned backend package against the advisory databases, at any severity — the lock is a full
|
||||
# `pip freeze`, so it is audited as written, without a resolver. An advisory with no fix is answered
|
||||
@@ -139,9 +194,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
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# The fast lint lanes on the uncommitted files only — ruff (check + format) on Python, eslint on the
|
||||
# frontend's code and prettier on all of the frontend — for a check after every edit instead of a
|
||||
# full gate run. It is a convenience, never the gate: scripts/check.sh stays what a merge or a
|
||||
# release requires.
|
||||
#
|
||||
# bash scripts/lint-changed.sh lint what `git status` shows as changed or new
|
||||
# bash scripts/lint-changed.sh --hook the same, as a Claude Code Stop hook: silent and exit 0 when
|
||||
# clean; otherwise the findings on stderr and exit 2, which
|
||||
# hands them back to the agent before it ends its turn
|
||||
#
|
||||
# Measured 2026-10-04: one cold eslint run (type-aware) takes 3.4–4.7 s, so this runs once per turn
|
||||
# (Stop), not after every edit — a warm daemon (eslint_d) would add a dependency and a stray process.
|
||||
set -u
|
||||
cd "$(dirname "$0")/.." || exit 2
|
||||
hook=0
|
||||
[ "${1:-}" = --hook ] && hook=1
|
||||
|
||||
if [ "$hook" = 1 ]; then
|
||||
# A Stop hook that blocked once is told so on the next stop; let that one through, or an unfixable
|
||||
# finding would keep the agent in a loop.
|
||||
grep -q '"stop_hook_active": *true' && exit 0
|
||||
fi
|
||||
|
||||
mapfile -t changed < <(git status --porcelain --untracked-files=all | sed -E 's/^.. //; s/.* -> //' |
|
||||
while read -r f; do [ -f "$f" ] && printf '%s\n' "$f"; done)
|
||||
# eslint reads code; prettier reads every file the gate's `prettier --check .` does (a locale JSON,
|
||||
# a stylesheet), and `--ignore-unknown` passes over the ones it has no parser for.
|
||||
py=() fe=() pretty=()
|
||||
for f in "${changed[@]}"; do
|
||||
case "$f" in
|
||||
backend/*.py | scripts/*.py) py+=("$f") ;;
|
||||
esac
|
||||
case "$f" in
|
||||
frontend/*.ts | frontend/*.tsx | frontend/*.js | frontend/*.mjs) fe+=("${f#frontend/}") ;;
|
||||
esac
|
||||
case "$f" in
|
||||
frontend/*) pretty+=("${f#frontend/}") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
report="$(
|
||||
if [ ${#py[@]} -gt 0 ]; then
|
||||
python -m ruff check --quiet --config backend/ruff.toml "${py[@]}" 2>&1
|
||||
python -m ruff format --check --quiet --config backend/ruff.toml "${py[@]}" 2>&1
|
||||
fi
|
||||
if [ ${#fe[@]} -gt 0 ]; then
|
||||
(cd frontend && npx eslint --max-warnings 0 "${fe[@]}" 2>&1)
|
||||
fi
|
||||
if [ ${#pretty[@]} -gt 0 ]; then
|
||||
(cd frontend && npx prettier --check --ignore-unknown --log-level warn "${pretty[@]}" 2>&1)
|
||||
fi
|
||||
)"
|
||||
|
||||
if [ -z "$report" ]; then
|
||||
[ "$hook" = 1 ] || echo "lint-changed: clean (${#py[@]} py, ${#pretty[@]} frontend)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$hook" = 1 ]; then
|
||||
printf 'lint-changed found problems in the uncommitted files:\n%s\n' "$report" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '%s\n' "$report"
|
||||
exit 1
|
||||
Reference in New Issue
Block a user