Compare commits
5
Commits
2b6d968833
...
c8247c0dde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8247c0dde | ||
|
|
722c37a6d0 | ||
|
|
5ba5679239 | ||
|
|
926e5e9899 | ||
|
|
9251918e08 |
@@ -7,6 +7,12 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.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
|
## 0.107.1 — 2026-10-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ services:
|
|||||||
POSTGRES_DB: siftlode_test
|
POSTGRES_DB: siftlode_test
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run
|
- /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:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U siftlode -d siftlode_test"]
|
test: ["CMD-SHELL", "pg_isready -U siftlode -d siftlode_test"]
|
||||||
interval: 1s
|
interval: 1s
|
||||||
@@ -37,4 +40,4 @@ services:
|
|||||||
- ./:/app
|
- ./:/app
|
||||||
working_dir: /app
|
working_dir: /app
|
||||||
# Coverage is on demand, not in the gate: see docs/development.md.
|
# 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.
|
# not an unknown marker that quietly does nothing.
|
||||||
testpaths = tests
|
testpaths = tests
|
||||||
pythonpath = .
|
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
|
ruff==0.15.21
|
||||||
httpx2==2.13.0
|
httpx2==2.13.0
|
||||||
pip-audit==2.10.1
|
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}"
|
||||||
@@ -44,6 +44,8 @@ A change is done when this is green, from the repo root:
|
|||||||
bash scripts/check.sh # every lane
|
bash scripts/check.sh # every lane
|
||||||
bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop
|
bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop
|
||||||
bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one
|
bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one
|
||||||
|
bash scripts/check.sh --list # the lane names, one per line (runs nothing)
|
||||||
|
bash scripts/check.sh --only "vitest" # one lane, by its exact name — never "the gate"
|
||||||
bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only
|
bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,6 +109,9 @@ Coverage is on demand, never enforced and not in the gate (E35): `npm run test -
|
|||||||
`frontend/`, and for the backend
|
`frontend/`, and for the backend
|
||||||
`docker compose -f backend/docker-compose.test.yml run --rm test python -m pytest --cov=app` (then
|
`docker compose -f backend/docker-compose.test.yml run --rm test python -m pytest --cov=app` (then
|
||||||
`… down -v`).
|
`… 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
|
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;
|
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).
|
exceptions in `.trivyignore.yaml`, each with a reason and an expiry).
|
||||||
|
|||||||
@@ -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 () => {
|
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);
|
vi.spyOn(api, "feed").mockResolvedValue({ items: [], next_cursor: null } as never);
|
||||||
let settle: (v: unknown) => void = () => {};
|
let settle: (v: unknown) => void = () => {};
|
||||||
vi.spyOn(api, "feedSummary").mockImplementation(((f: FeedFilters) =>
|
const summary = vi
|
||||||
f.q === "kitchen"
|
.spyOn(api, "feedSummary")
|
||||||
? Promise.resolve({ count: 1937, facets: {} })
|
.mockImplementation(((f: FeedFilters) =>
|
||||||
: new Promise((r) => (settle = r))) as never);
|
f.q === "kitchen"
|
||||||
|
? Promise.resolve({ count: 1937, facets: {} })
|
||||||
|
: new Promise((r) => (settle = r))) as never);
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||||
const pill = () => screen.getByTestId("item-count");
|
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"));
|
await waitFor(() => expect(pill().getAttribute("aria-busy")).toBe("true"));
|
||||||
expect(pill().getAttribute("title")).toBe("1,937"); // still the old figure, now marked as such
|
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: {} });
|
settle({ count: 178991, facets: {} });
|
||||||
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
|
await waitFor(() => expect(pill().getAttribute("title")).toBe("178,991"));
|
||||||
expect(pill().getAttribute("aria-busy")).toBeNull();
|
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
|
// 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.
|
// list. i18n is key-passthrough, so labels read as keys.
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
act,
|
act,
|
||||||
cleanup,
|
cleanup,
|
||||||
@@ -118,6 +118,17 @@ import Files from "./Files";
|
|||||||
import { FILES_LIST_CAP, type QueueJob } from "../lib/api";
|
import { FILES_LIST_CAP, type QueueJob } from "../lib/api";
|
||||||
import { noteQueue, resetQueueWatch, useFinished } from "../lib/downloadQueue";
|
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 =>
|
const file = (id: number, extra: Partial<LibraryFile> = {}): LibraryFile =>
|
||||||
({
|
({
|
||||||
id,
|
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", () => {
|
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.folders = [YT];
|
||||||
mocks.files = [file(1, { display_name: "Recept" }), file(2)];
|
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 { api, type FileFolder } from "../lib/api";
|
||||||
import { defaultLayout } from "../lib/panelLayout";
|
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(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -251,10 +257,7 @@ describe("FilesRail · a folder shared by link", () => {
|
|||||||
.getAllByRole("menuitem", { hidden: true })
|
.getAllByRole("menuitem", { hidden: true })
|
||||||
.find((b) => b.textContent === "files.folder.shareLink")!,
|
.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")).toBeTruthy();
|
||||||
expect(
|
|
||||||
await screen.findByText("downloads.share.folderLinkHint", {}, { timeout: 5000 }),
|
|
||||||
).toBeTruthy();
|
|
||||||
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
|
expect(await screen.findByDisplayValue(`${window.location.origin}/watch/f`)).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// on <body> with the menu open — it lands on the first row that came back.
|
||||||
const retry = (await screen.findByText("common.retry")).closest("button")!;
|
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();
|
retry.focus();
|
||||||
fireEvent.click(retry);
|
fireEvent.click(retry);
|
||||||
const beta = await checkRow("Beta");
|
const beta = await checkRow("Beta");
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ import { pushLayer, removeLayer } from "../lib/layerStack";
|
|||||||
|
|
||||||
type Media = HTMLMediaElement & { _t?: number; _paused?: boolean };
|
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(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -504,8 +509,7 @@ describe("a collection sibling in the info overlay", () => {
|
|||||||
await playingAt("/media/A", 100);
|
await playingAt("/media/A", 100);
|
||||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||||
fireEvent.click(await menuItem("plex.player.infoBtn"));
|
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" });
|
||||||
const restart = await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 });
|
|
||||||
fireEvent.click(restart);
|
fireEvent.click(restart);
|
||||||
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
|
await waitFor(() => expect(lastStart(session)).toEqual(["B", 0]));
|
||||||
expect(screen.queryByRole("heading", { name: "Saga" })).toBeNull(); // the overlay closed
|
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;
|
const asked = session.mock.calls.length;
|
||||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||||
fireEvent.click(await menuItem("plex.player.infoBtn"));
|
fireEvent.click(await menuItem("plex.player.infoBtn"));
|
||||||
fireEvent.click(
|
fireEvent.click(await screen.findByRole("button", { name: "card.restart" }));
|
||||||
await screen.findByRole("button", { name: "card.restart" }, { timeout: 5000 }),
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(video()._t).toBe(0));
|
await waitFor(() => expect(video()._t).toBe(0));
|
||||||
expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload
|
expect(session.mock.calls.length).toBe(asked); // the attached session seeks, no reload
|
||||||
expect(video().paused).toBe(false);
|
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());
|
act(() => void video().pause());
|
||||||
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
fireEvent.click(screen.getByLabelText("plex.player.more"));
|
||||||
fireEvent.click(await menuItem("plex.player.infoBtn"));
|
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;
|
const plays = play.mock.calls.length;
|
||||||
fireEvent.click(restart);
|
fireEvent.click(restart);
|
||||||
await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1));
|
await waitFor(() => expect(session.mock.calls.length).toBe(asked + 1));
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ beforeAll(async () => {
|
|||||||
await import("./Welcome");
|
await import("./Welcome");
|
||||||
}, 60_000);
|
}, 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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -85,7 +89,7 @@ function refusal(
|
|||||||
return () => Promise.reject(new HttpError(403, "ignored", { code: "login", ...detail }));
|
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 () => {
|
it("names the address the account is on, not the one that was typed", async () => {
|
||||||
const passwordLogin = vi.fn();
|
const passwordLogin = vi.fn();
|
||||||
const { HttpError } = await mountCard("/", { passwordLogin });
|
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
|
// 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
|
// 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.
|
// 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 () => {
|
it("refuses to draw a form that cannot succeed", async () => {
|
||||||
await mountCard("/#reset=dead-token", {
|
await mountCard("/#reset=dead-token", {
|
||||||
checkPasswordReset: () => Promise.resolve({ ok: false }),
|
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 () => {
|
it("names the address the request was recorded for", async () => {
|
||||||
await mountCard("/#access=requested&email=nptemu%40gmail.com");
|
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.
|
// i18n is mocked to a key-passthrough; the API is stubbed so nothing reaches out.
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
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) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -42,6 +42,9 @@ async function failTwice(fail: () => void, message: string) {
|
|||||||
fail();
|
fail();
|
||||||
const first = await screen.findByRole("alert");
|
const first = await screen.findByRole("alert");
|
||||||
expect(first.textContent).toBe(message);
|
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();
|
fail();
|
||||||
const second = screen.getByRole("alert");
|
const second = screen.getByRole("alert");
|
||||||
expect(second.textContent).toBe(message);
|
expect(second.textContent).toBe(message);
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ afterEach(() => {
|
|||||||
|
|
||||||
const bodyRows = () => host!.querySelectorAll("tbody tr").length;
|
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.
|
// Mounts synchronously, so what the DOM holds right after is the first render alone.
|
||||||
function mount(rows: Row[], columns: Column<Row>[], narrowedBy = "") {
|
function mount(rows: Row[], columns: Column<Row>[], narrowedBy = "") {
|
||||||
host = document.createElement("div");
|
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 () => {
|
it("paints its first rows at once and the rest after", async () => {
|
||||||
mount(many(200), plain);
|
mount(many(200), plain);
|
||||||
expect(bodyRows()).toBe(FIRST_ROWS);
|
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 () => {
|
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);
|
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.
|
// The first row was drawn by the first render, not again by each step after it.
|
||||||
expect(renders.get("r0")).toBe(1);
|
expect(renders.get("r0")).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("starts over from the first rows when the caller narrows them, not on new data", async () => {
|
it("starts over from the first rows when the caller narrows them, not on new data", async () => {
|
||||||
const draw = mount(many(200), plain, "");
|
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.
|
// New data for the same view (a refetch): the page keeps what it shows.
|
||||||
draw(many(200), "");
|
draw(many(200), "");
|
||||||
expect(bodyRows()).toBe(200);
|
expect(bodyRows()).toBe(200);
|
||||||
// A search the caller applied: the urgent render is small again.
|
// A search the caller applied: the urgent render is small again.
|
||||||
draw(many(150, "s"), "s");
|
draw(many(150, "s"), "s");
|
||||||
expect(bodyRows()).toBe(FIRST_ROWS);
|
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
|
// Channels turns the page to the row a priority pick moved and focuses it in the commit
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.107.1",
|
||||||
"date": "2026-10-16",
|
"date": "2026-10-16",
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
"version": "0.107.1",
|
||||||
"date": "2026-10-16",
|
"date": "2026-10-16",
|
||||||
|
|||||||
@@ -33,13 +33,18 @@ beforeAll(async () => {
|
|||||||
await import("./api/core");
|
await import("./api/core");
|
||||||
}, 60_000);
|
}, 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(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
vi.resetModules(); // both stores load at import: each test needs a fresh one
|
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 () => {
|
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.notifications.5", JSON.stringify([entry(7, "older")]));
|
||||||
localStorage.setItem("siftlode.notifSettings.5", JSON.stringify({ durationMs: 0 }));
|
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 () => {
|
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.
|
// BootGate moves a tab whose account was signed out elsewhere onto the one the server answers as.
|
||||||
sessionStorage.setItem("siftlode.activeAccount", "4");
|
sessionStorage.setItem("siftlode.activeAccount", "4");
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ beforeAll(async () => {
|
|||||||
slices[name] = slice;
|
slices[name] = slice;
|
||||||
}
|
}
|
||||||
api = ((await import("../api")) as { api: Slice }).api;
|
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", () => {
|
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)", () => {
|
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", () => {
|
describe("a hand-rolled button", () => {
|
||||||
it("is refused in a feature component, the lab and lib, raw or focus-safe", async () => {
|
// Each new path's first lint makes the type-aware project service load it (0.4–1 s apiece, a repeat
|
||||||
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
|
// ~0): this case lints four, ~1.4 s idle, past 5 s on a busy machine.
|
||||||
expect(await hits(raw)).toBe(1);
|
it(
|
||||||
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
|
"is refused in a feature component, the lab and lib, raw or focus-safe",
|
||||||
expect(await hits(raw, "src/lib/__probe.tsx")).toBe(1);
|
{ timeout: 30_000 },
|
||||||
// …and in a ui/ COMPOSITE: a pager or a side panel is built from the primitives too.
|
async () => {
|
||||||
expect(await hits(raw, "src/components/ui/Pager.tsx")).toBe(1);
|
const raw = 'export const A = () => <button className="px-3">x</button>;\n';
|
||||||
expect(
|
expect(await hits(raw)).toBe(1);
|
||||||
await hits(
|
expect(await hits(raw, "src/design-lab/__probe.tsx")).toBe(1);
|
||||||
'import { FocusSafeButton } from "./ui/FocusSafeButton";\nexport const B = () => <FocusSafeButton>x</FocusSafeButton>;\n',
|
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.
|
||||||
).toBe(1);
|
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 () => {
|
it("is allowed only in a control primitive, and the primitives pass everywhere", async () => {
|
||||||
for (const file of ["IconButton.tsx", "SegmentedToggle.tsx", "AnchoredPopover.tsx"])
|
for (const file of ["IconButton.tsx", "SegmentedToggle.tsx", "AnchoredPopover.tsx"])
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ beforeAll(async () => {
|
|||||||
await import("./api");
|
await import("./api");
|
||||||
}, 60_000);
|
}, 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(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -69,7 +74,7 @@ const raised = (level: "DEBUG" | "TRACE", hours = 24) => ({
|
|||||||
const codes = (batch: unknown[] | undefined) =>
|
const codes = (batch: unknown[] | undefined) =>
|
||||||
(batch ?? []).map((e) => (e as { event: string }).event);
|
(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 () => {
|
it("the base level at boot sets INFO, streams nothing and logs nothing", async () => {
|
||||||
const d = await fresh();
|
const d = await fresh();
|
||||||
d.applyDiagnostics(
|
d.applyDiagnostics(
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ beforeAll(async () => {
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
let current: typeof import("./diagnostics") | null = null;
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -73,7 +78,7 @@ async function fresh() {
|
|||||||
|
|
||||||
const flat = () => sent.flat();
|
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 () => {
|
it("raises the base level to its own, and tags and streams what it keeps", async () => {
|
||||||
const d = await fresh();
|
const d = await fresh();
|
||||||
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
|
d.applyDiagnostics(BASE, recording("DEBUG"), () => {});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { relative } from "node:path";
|
import { relative, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { ESLint, type Linter } from "eslint";
|
import { ESLint, type Linter } from "eslint";
|
||||||
import prettierConfig from "eslint-config-prettier";
|
import prettierConfig from "eslint-config-prettier";
|
||||||
@@ -375,6 +376,37 @@ describe("the gate snapshot (gates.snapshot.json)", () => {
|
|||||||
}, 120_000);
|
}, 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)", () => {
|
describe("the gate register (docs/gates.md)", () => {
|
||||||
it("lists every ESLint rule narrowed, by scope", async () => {
|
it("lists every ESLint rule narrowed, by scope", async () => {
|
||||||
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
|
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
|
||||||
@@ -552,7 +584,10 @@ describe("the gate register (docs/gates.md)", () => {
|
|||||||
"[pytest]",
|
"[pytest]",
|
||||||
"testpaths = tests",
|
"testpaths = tests",
|
||||||
"pythonpath = .",
|
"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",
|
" POSTGRES_DB: siftlode_test",
|
||||||
" tmpfs:",
|
" tmpfs:",
|
||||||
" - /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run",
|
" - /var/lib/postgresql/data # ephemeral + fast; the schema is rebuilt per run",
|
||||||
|
" command: [\"postgres\", \"-c\", \"max_connections=300\"]",
|
||||||
" healthcheck:",
|
" healthcheck:",
|
||||||
" test: [\"CMD-SHELL\", \"pg_isready -U siftlode -d siftlode_test\"]",
|
" test: [\"CMD-SHELL\", \"pg_isready -U siftlode -d siftlode_test\"]",
|
||||||
" interval: 1s",
|
" interval: 1s",
|
||||||
@@ -1250,7 +1251,7 @@
|
|||||||
" volumes:",
|
" volumes:",
|
||||||
" - ./:/app",
|
" - ./:/app",
|
||||||
" working_dir: /app",
|
" working_dir: /app",
|
||||||
" command: [\"python\", \"-m\", \"pytest\"]"
|
" command: [\"python\", \"-m\", \"pytest\", \"-n\", \"auto\"]"
|
||||||
],
|
],
|
||||||
"frontend/.prettierignore": [
|
"frontend/.prettierignore": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -1298,16 +1299,24 @@
|
|||||||
"cd \"$(dirname \"$0\")/..\" || exit 2",
|
"cd \"$(dirname \"$0\")/..\" || exit 2",
|
||||||
"mode=all",
|
"mode=all",
|
||||||
"brief=0",
|
"brief=0",
|
||||||
"for arg in \"$@\"; do",
|
"list=0",
|
||||||
" case \"$arg\" in",
|
"only=\"\"",
|
||||||
" all | lint) mode=\"$arg\" ;;",
|
"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 ;;",
|
" --brief) brief=1 ;;",
|
||||||
|
" --list) list=1 ;;",
|
||||||
|
" --only) [ $# -ge 2 ] || usage; only=\"$2\"; shift ;;",
|
||||||
" esac",
|
" esac",
|
||||||
|
" shift",
|
||||||
"done",
|
"done",
|
||||||
|
"[ \"$list\" = 1 ] && [ -n \"$only\" ] && usage",
|
||||||
"export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below",
|
"export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below",
|
||||||
"TESTCOMPOSE=backend/docker-compose.test.yml",
|
"TESTCOMPOSE=backend/docker-compose.test.yml",
|
||||||
"failed=()",
|
"failed=()",
|
||||||
"timings=()",
|
"timings=()",
|
||||||
|
"ran=0",
|
||||||
"ms() { date +%s%3N; }",
|
"ms() { date +%s%3N; }",
|
||||||
"secs() { awk -v t=\"$1\" 'BEGIN { printf \"%.1f\", t / 1000 }'; }",
|
"secs() { awk -v t=\"$1\" 'BEGIN { printf \"%.1f\", t / 1000 }'; }",
|
||||||
"took=\"\"",
|
"took=\"\"",
|
||||||
@@ -1315,7 +1324,7 @@
|
|||||||
" took=\"$(secs $(($(ms) - $3)))\"",
|
" took=\"$(secs $(($(ms) - $3)))\"",
|
||||||
" timings+=(\"$(printf '%8s s %s' \"$took\" \"$1\")$([ \"$2\" = red ] && echo ' (red)')\")",
|
" timings+=(\"$(printf '%8s s %s' \"$took\" \"$1\")$([ \"$2\" = red ] && echo ' (red)')\")",
|
||||||
" if [ -n \"${GATE_TIMES_FILE:-}\" ]; then",
|
" if [ -n \"${GATE_TIMES_FILE:-}\" ]; then",
|
||||||
" printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"$(date -u +%FT%TZ)\" \"$mode\" \"$1\" \"$took\" \"$2\" \\",
|
" printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"$(date -u +%FT%TZ)\" \"${only:+only}${only:-$mode}\" \"$1\" \"$took\" \"$2\" \\",
|
||||||
" \"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)\" >>\"$GATE_TIMES_FILE\"",
|
" \"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)\" >>\"$GATE_TIMES_FILE\"",
|
||||||
" fi",
|
" fi",
|
||||||
" return 0",
|
" return 0",
|
||||||
@@ -1323,6 +1332,9 @@
|
|||||||
"lane() { # lane <name> <command…> — run it, remember a failure, never stop",
|
"lane() { # lane <name> <command…> — run it, remember a failure, never stop",
|
||||||
" local name=\"$1\" t0 result=ok",
|
" local name=\"$1\" t0 result=ok",
|
||||||
" shift",
|
" shift",
|
||||||
|
" if [ \"$list\" = 1 ]; then echo \"$name\"; return 0; fi",
|
||||||
|
" [ -n \"$only\" ] && [ \"$name\" != \"$only\" ] && return 0",
|
||||||
|
" ran=$((ran + 1))",
|
||||||
" t0=\"$(ms)\"",
|
" t0=\"$(ms)\"",
|
||||||
" if [ \"$brief\" = 0 ]; then",
|
" if [ \"$brief\" = 0 ]; then",
|
||||||
" echo \">> $name…\"",
|
" echo \">> $name…\"",
|
||||||
@@ -1406,14 +1418,21 @@
|
|||||||
" lane \"pip-audit (backend lock)\" pip_audit",
|
" lane \"pip-audit (backend lock)\" pip_audit",
|
||||||
" lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high",
|
" lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high",
|
||||||
"fi",
|
"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:\"",
|
"echo \">> lane times:\"",
|
||||||
"printf '%s\\n' \"${timings[@]}\"",
|
"printf '%s\\n' \"${timings[@]}\"",
|
||||||
"echo \"$(printf '%8s' \"$(secs $(($(ms) - gate_t0)))\") s total\"",
|
"echo \"$(printf '%8s' \"$(secs $(($(ms) - gate_t0)))\") s total\"",
|
||||||
"if [ ${#failed[@]} -eq 0 ]; then",
|
"if [ ${#failed[@]} -eq 0 ]; then",
|
||||||
" echo \">> gate ($mode): green ✓\"",
|
" echo \">> $scope: green ✓\"",
|
||||||
" exit 0",
|
" exit 0",
|
||||||
"fi",
|
"fi",
|
||||||
"echo \">> gate ($mode): RED — ${failed[*]}\" >&2",
|
"echo \">> $scope: RED — ${failed[*]}\" >&2",
|
||||||
"exit 1"
|
"exit 1"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,16 +14,22 @@ async function hits(source: string, filePath = "src/components/__probe.tsx"): Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("a hand-rolled field", () => {
|
describe("a hand-rolled field", () => {
|
||||||
it("is refused anywhere outside the field primitives, text box or native control", async () => {
|
// Each new path's first lint makes the type-aware project service load it (0.4–1 s apiece, a repeat
|
||||||
const text = 'export const A = () => <input className="border px-2" />;\n';
|
// ~0): this case lints three, ~1 s idle, past 5 s on a busy machine.
|
||||||
expect(await hits(text)).toBe(1);
|
it(
|
||||||
expect(await hits(text, "src/design-lab/__probe.tsx")).toBe(1);
|
"is refused anywhere outside the field primitives, text box or native control",
|
||||||
// …and in a ui/ composite: the pager's page box is an Input too.
|
{ timeout: 30_000 },
|
||||||
expect(await hits(text, "src/components/ui/Pager.tsx")).toBe(1);
|
async () => {
|
||||||
expect(await hits("export const B = () => <textarea />;\n")).toBe(1);
|
const text = 'export const A = () => <input className="border px-2" />;\n';
|
||||||
expect(await hits('export const C = () => <input type="checkbox" />;\n')).toBe(1);
|
expect(await hits(text)).toBe(1);
|
||||||
expect(await hits('export const D = () => <input type="range" />;\n')).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 () => {
|
it("passes in a field primitive, for a file picker, and for the primitives themselves", async () => {
|
||||||
for (const file of ["Input.tsx", "NativeControls.tsx"])
|
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("|"));
|
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();
|
const files = tracked();
|
||||||
expect(files.length).toBeGreaterThan(1000); // a scan that reads nothing is green over nothing
|
expect(files.length).toBeGreaterThan(1000); // a scan that reads nothing is green over nothing
|
||||||
const hits = files.flatMap((f) =>
|
const hits = files.flatMap((f) =>
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ function files(dir: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("removed features stay removed", () => {
|
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)
|
const hits = ROOTS.flatMap(files)
|
||||||
.filter((f) => !ALLOWED.has(f))
|
.filter((f) => !ALLOWED.has(f))
|
||||||
.flatMap((f) =>
|
.flatMap((f) =>
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ beforeAll(async () => {
|
|||||||
await import("./useVerifyEntry");
|
await import("./useVerifyEntry");
|
||||||
}, 60_000);
|
}, 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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
@@ -52,7 +57,7 @@ async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
|||||||
return () => screen.getByTestId("outcome").textContent;
|
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 () => {
|
it("posts the fragment token and reports the confirmed address", async () => {
|
||||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "someone@example.com" });
|
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
|
// 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
|
// 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.
|
// 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", () => {
|
describe(
|
||||||
it("consumes a token delivered by hashchange, and clears it from the address bar", async () => {
|
"a verification link that arrives as a fragment-only navigation",
|
||||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "pasted@example.com" });
|
{ timeout: CASE_BUDGET },
|
||||||
const outcome = await mountAt("/", verifyEmail);
|
() => {
|
||||||
expect(verifyEmail).not.toHaveBeenCalled();
|
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 () => {
|
await act(async () => {
|
||||||
window.history.replaceState(null, "", "/#verify=pasted-token");
|
window.history.replaceState(null, "", "/#verify=pasted-token");
|
||||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
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" });
|
|
||||||
});
|
});
|
||||||
const outcome = await mountAt("/#verify=first-token", verifyEmail);
|
|
||||||
expect(outcome()).toBe("ok:first@example.com");
|
|
||||||
|
|
||||||
await act(async () => {
|
expect(verifyEmail).toHaveBeenCalledWith("pasted-token");
|
||||||
window.history.replaceState(null, "", "/#verify=spent-token");
|
expect(outcome()).toBe("ok:pasted@example.com");
|
||||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
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 () => {
|
await act(async () => {
|
||||||
const verifyEmail = vi.fn().mockResolvedValue({ ok: true, email: "same@example.com" });
|
window.history.replaceState(null, "", "/#verify=spent-token");
|
||||||
await mountAt("/#verify=same-token", verifyEmail);
|
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||||
|
});
|
||||||
|
|
||||||
await act(async () => {
|
expect(outcome()).toBe("invalid:-");
|
||||||
window.history.replaceState(null, "", "/#verify=same-token");
|
|
||||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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([]);
|
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.
|
// 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"));
|
const app = reach(join(SRC, "main.tsx"));
|
||||||
expect(app.has("src/i18n/index.ts")).toBe(true);
|
expect(app.has("src/i18n/index.ts")).toBe(true);
|
||||||
|
|||||||
+29
-7
@@ -9,6 +9,10 @@
|
|||||||
# bash scripts/check.sh lint the static lanes only: no tests, no build, no audits
|
# 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
|
# … --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
|
# 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,
|
# 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
|
# ok|red, HEAD's tree) — to compare runs; every run prints the times anyway
|
||||||
#
|
#
|
||||||
@@ -20,18 +24,26 @@ set -u
|
|||||||
cd "$(dirname "$0")/.." || exit 2
|
cd "$(dirname "$0")/.." || exit 2
|
||||||
mode=all
|
mode=all
|
||||||
brief=0
|
brief=0
|
||||||
for arg in "$@"; do
|
list=0
|
||||||
case "$arg" in
|
only=""
|
||||||
all | lint) mode="$arg" ;;
|
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 ;;
|
--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
|
esac
|
||||||
|
shift
|
||||||
done
|
done
|
||||||
|
[ "$list" = 1 ] && [ -n "$only" ] && usage
|
||||||
|
|
||||||
export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below
|
export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below
|
||||||
TESTCOMPOSE=backend/docker-compose.test.yml
|
TESTCOMPOSE=backend/docker-compose.test.yml
|
||||||
failed=()
|
failed=()
|
||||||
timings=()
|
timings=()
|
||||||
|
ran=0
|
||||||
ms() { date +%s%3N; }
|
ms() { date +%s%3N; }
|
||||||
secs() { awk -v t="$1" 'BEGIN { printf "%.1f", t / 1000 }'; }
|
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
|
# How long each lane took: a line after the lane, a table at the end, and — when GATE_TIMES_FILE names a
|
||||||
@@ -42,7 +54,7 @@ timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it mus
|
|||||||
took="$(secs $(($(ms) - $3)))"
|
took="$(secs $(($(ms) - $3)))"
|
||||||
timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')")
|
timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')")
|
||||||
if [ -n "${GATE_TIMES_FILE:-}" ]; then
|
if [ -n "${GATE_TIMES_FILE:-}" ]; then
|
||||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$mode" "$1" "$took" "$2" \
|
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "${only:+only}${only:-$mode}" "$1" "$took" "$2" \
|
||||||
"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE"
|
"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE"
|
||||||
fi
|
fi
|
||||||
return 0
|
return 0
|
||||||
@@ -50,6 +62,9 @@ timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it mus
|
|||||||
lane() { # lane <name> <command…> — run it, remember a failure, never stop
|
lane() { # lane <name> <command…> — run it, remember a failure, never stop
|
||||||
local name="$1" t0 result=ok
|
local name="$1" t0 result=ok
|
||||||
shift
|
shift
|
||||||
|
if [ "$list" = 1 ]; then echo "$name"; return 0; fi
|
||||||
|
[ -n "$only" ] && [ "$name" != "$only" ] && return 0
|
||||||
|
ran=$((ran + 1))
|
||||||
t0="$(ms)"
|
t0="$(ms)"
|
||||||
if [ "$brief" = 0 ]; then
|
if [ "$brief" = 0 ]; then
|
||||||
echo ">> $name…"
|
echo ">> $name…"
|
||||||
@@ -163,12 +178,19 @@ if [ "$mode" = all ]; then
|
|||||||
lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high
|
lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high
|
||||||
fi
|
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:"
|
echo ">> lane times:"
|
||||||
printf '%s\n' "${timings[@]}"
|
printf '%s\n' "${timings[@]}"
|
||||||
echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total"
|
echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total"
|
||||||
if [ ${#failed[@]} -eq 0 ]; then
|
if [ ${#failed[@]} -eq 0 ]; then
|
||||||
echo ">> gate ($mode): green ✓"
|
echo ">> $scope: green ✓"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo ">> gate ($mode): RED — ${failed[*]}" >&2
|
echo ">> $scope: RED — ${failed[*]}" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user