mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
Migrate steam-backlog-enforcer/web to Astro (no R…
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Todo: 9b9342ba
This commit is contained in:
parent
8cc31a2c87
commit
cef9fccab8
@ -20,6 +20,9 @@ repos:
|
|||||||
args: [--markdown-linebreak-ext=md]
|
args: [--markdown-linebreak-ext=md]
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
# pnpm 12 writes pnpm-lock.yaml as two YAML documents (the pinned
|
||||||
|
# packageManager's own lock, then the project lock); parse both.
|
||||||
|
args: [--allow-multiple-documents]
|
||||||
- id: check-json
|
- id: check-json
|
||||||
- id: check-toml
|
- id: check-toml
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
@ -177,7 +180,7 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: vitest-coverage
|
- id: vitest-coverage
|
||||||
name: vitest with 100% coverage enforcement
|
name: vitest with 100% coverage enforcement
|
||||||
entry: npm --prefix web run coverage
|
entry: pnpm --dir web coverage
|
||||||
language: system
|
language: system
|
||||||
files: ^web/src/
|
files: ^web/src/
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
The web UI replaces `run.sh` as the primary way to drive the enforcer. Every
|
The web UI replaces `run.sh` as the primary way to drive the enforcer. Every
|
||||||
CLI command is reachable from the UI; the CLI stays as the backend, the
|
CLI command is reachable from the UI; the CLI stays as the backend, the
|
||||||
systemd entry point and the recovery path. This file is the frozen contract
|
systemd entry point and the recovery path. This file is the frozen contract
|
||||||
between the Python server, the root daemon and the React app. The TypeScript
|
between the Python server, the root daemon and the Astro web UI. The TypeScript
|
||||||
mirror is `web/src/api/contract.ts`; change both together or neither.
|
mirror is `web/src/api/contract.ts`; change both together or neither.
|
||||||
|
|
||||||
## Trust model
|
## Trust model
|
||||||
@ -26,13 +26,13 @@ mirror is `web/src/api/contract.ts`; change both together or neither.
|
|||||||
- A per-launch token (32 random bytes, hex) is written to
|
- A per-launch token (32 random bytes, hex) is written to
|
||||||
`$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-<port>.token` (mode 0600; one
|
`$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-<port>.token` (mode 0600; one
|
||||||
file per port, so a second server never clobbers the live one's) and
|
file per port, so a second server never clobbers the live one's) and
|
||||||
injected into `index.html` as `<meta name="sbe-token" content="…">`.
|
injected into every served `.html` page as `<meta name="sbe-token" content="…">`.
|
||||||
Mutating requests send it as `X-SBE-Token`; `EventSource` sends it as
|
Mutating requests send it as `X-SBE-Token`; `EventSource` sends it as
|
||||||
`?token=`. Missing or wrong token → `403 bad_token`; the UI then reloads
|
`?token=`. Missing or wrong token → `403 bad_token`; the UI then reloads
|
||||||
the page once (at most once per 30 s) to pick up a restarted server's token.
|
the page once (at most once per 30 s) to pick up a restarted server's token.
|
||||||
- Dev (`npm run dev`): Vite proxies `/api` to `127.0.0.1:8000` (`SBE_PORT`
|
- Dev (`pnpm dev`): Astro proxies `/api` to `127.0.0.1:8000` (`SBE_PORT`
|
||||||
overrides) with `Host` and `Origin` rewritten to that origin, and injects
|
overrides) with `Host` and `Origin` rewritten to that origin, and renders
|
||||||
the token meta from that port's token file (serve only, never in a build).
|
the token meta from that port's token file (dev only, never in a build).
|
||||||
- Secrets (`steam_api_key`) are write-only: accepted by `POST /api/setup`,
|
- Secrets (`steam_api_key`) are write-only: accepted by `POST /api/setup`,
|
||||||
never returned by any endpoint.
|
never returned by any endpoint.
|
||||||
|
|
||||||
|
|||||||
@ -18,26 +18,26 @@ from steam_backlog_enforcer.game_install import _echo
|
|||||||
WEB_DIR = (Path(__file__).resolve().parent.parent / "web").resolve()
|
WEB_DIR = (Path(__file__).resolve().parent.parent / "web").resolve()
|
||||||
_BUILT_MARKER = WEB_DIR / "dist" / "index.html"
|
_BUILT_MARKER = WEB_DIR / "dist" / "index.html"
|
||||||
|
|
||||||
# Everything whose change invalidates the built bundle. Anything under src,
|
# Everything whose change invalidates the built bundle. Anything under src
|
||||||
# plus the build configuration itself - a vite/tsconfig edit changes the
|
# (pages, styles, scripts) or public (copied verbatim into dist), plus the
|
||||||
# output even when not one line of src moved.
|
# build configuration itself - an astro/tsconfig edit or a dependency bump
|
||||||
_SOURCE_DIR = WEB_DIR / "src"
|
# changes the output even when not one line of src moved.
|
||||||
|
_SOURCE_DIRS = (WEB_DIR / "src", WEB_DIR / "public")
|
||||||
_SOURCE_FILES = (
|
_SOURCE_FILES = (
|
||||||
WEB_DIR / "index.html",
|
WEB_DIR / "astro.config.mjs",
|
||||||
WEB_DIR / "package.json",
|
WEB_DIR / "package.json",
|
||||||
WEB_DIR / "vite.config.ts",
|
WEB_DIR / "pnpm-lock.yaml",
|
||||||
|
WEB_DIR / "pnpm-workspace.yaml",
|
||||||
WEB_DIR / "tsconfig.json",
|
WEB_DIR / "tsconfig.json",
|
||||||
WEB_DIR / "tsconfig.app.json",
|
|
||||||
WEB_DIR / "tsconfig.node.json",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_BUILD_TIMEOUT_SECONDS = 600
|
_BUILD_TIMEOUT_SECONDS = 600
|
||||||
|
|
||||||
_NPM_MISSING = (
|
_PNPM_MISSING = (
|
||||||
"npm was not found on PATH, so the stale web bundle cannot be rebuilt.\n"
|
"pnpm was not found on PATH, so the stale web bundle cannot be rebuilt.\n"
|
||||||
"npm is installed via nvm here, which only puts it on an interactive\n"
|
"A systemd unit or cron job may not see a pnpm installed per user.\n"
|
||||||
"shell's PATH - a systemd unit or cron job will not see it. Either run\n"
|
"Either run serve from a normal shell, or build once by hand:\n"
|
||||||
"serve from a normal shell, or build once by hand: cd web && npm run build"
|
"cd web && pnpm install && pnpm build"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -59,26 +59,27 @@ def frontend_is_stale() -> bool:
|
|||||||
if built == 0.0:
|
if built == 0.0:
|
||||||
return WEB_DIR.is_dir()
|
return WEB_DIR.is_dir()
|
||||||
sources = list(_SOURCE_FILES)
|
sources = list(_SOURCE_FILES)
|
||||||
if _SOURCE_DIR.is_dir():
|
for source_dir in _SOURCE_DIRS:
|
||||||
sources.extend(path for path in _SOURCE_DIR.rglob("*") if path.is_file())
|
if source_dir.is_dir():
|
||||||
|
sources.extend(path for path in source_dir.rglob("*") if path.is_file())
|
||||||
return any(_mtime(path) > built for path in sources)
|
return any(_mtime(path) > built for path in sources)
|
||||||
|
|
||||||
|
|
||||||
def build_frontend() -> bool:
|
def build_frontend() -> bool:
|
||||||
"""Run ``npm run build`` in ``web/``.
|
"""Run ``pnpm build`` in ``web/``.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the bundle was rebuilt, False if the build failed (the
|
True if the bundle was rebuilt, False if the build failed (the
|
||||||
captured output is printed either way).
|
captured output is printed either way).
|
||||||
"""
|
"""
|
||||||
npm = shutil.which("npm")
|
pnpm = shutil.which("pnpm")
|
||||||
if npm is None:
|
if pnpm is None:
|
||||||
_echo(_NPM_MISSING)
|
_echo(_PNPM_MISSING)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
_echo("Frontend sources changed - rebuilding web/dist ...", flush=True)
|
_echo("Frontend sources changed - rebuilding web/dist ...", flush=True)
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[npm, "run", "build"],
|
[pnpm, "build"],
|
||||||
cwd=WEB_DIR,
|
cwd=WEB_DIR,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
@ -5,7 +5,7 @@ DNS-rebinding guard, :mod:`._web_auth`), fails closed on stale code
|
|||||||
(:mod:`._serve_stale`), checks ``Origin`` and the per-launch token on
|
(:mod:`._serve_stale`), checks ``Origin`` and the per-launch token on
|
||||||
anything that is not a GET (and on the SSE stream), then hands off to a
|
anything that is not a GET (and on the SSE stream), then hands off to a
|
||||||
route from :mod:`._web_routes`, the SSE streamer (:mod:`._web_sse`) or the
|
route from :mod:`._web_routes`, the SSE streamer (:mod:`._web_sse`) or the
|
||||||
static bundle (``web/dist``, with the token injected into ``index.html``).
|
static bundle (``web/dist``, with the token injected into every ``.html`` page).
|
||||||
|
|
||||||
Binds to loopback only; never serves secrets and never sends CORS headers.
|
Binds to loopback only; never serves secrets and never sends CORS headers.
|
||||||
In development the Vite dev server proxies ``/api`` here; in production the
|
In development the Vite dev server proxies ``/api`` here; in production the
|
||||||
@ -43,7 +43,8 @@ WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve()
|
|||||||
DEFAULT_HOST = "127.0.0.1"
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
DEFAULT_PORT = 8000
|
DEFAULT_PORT = 8000
|
||||||
_INDEX = "index.html"
|
_INDEX = "index.html"
|
||||||
_NOT_BUILT_MSG = b"Frontend not built. Run: cd web && npm install && npm run build"
|
_NOT_FOUND_PAGE = "404.html"
|
||||||
|
_NOT_BUILT_MSG = b"Frontend not built. Run: cd web && pnpm install && pnpm build"
|
||||||
_STALE_MSG = (
|
_STALE_MSG = (
|
||||||
"This server is running outdated code and has stopped answering rather "
|
"This server is running outdated code and has stopped answering rather "
|
||||||
"than report numbers the enforcer is not applying. It restarts on "
|
"than report numbers the enforcer is not applying. It restarts on "
|
||||||
@ -159,12 +160,17 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def _serve_static(self, path: str) -> None:
|
def _serve_static(self, path: str) -> None:
|
||||||
"""Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard."""
|
"""Serve a file from ``WEB_DIST``: one static page per directory.
|
||||||
rel = path.lstrip("/") or _INDEX
|
|
||||||
candidate = (WEB_DIST / rel).resolve()
|
``/library`` and ``/library/`` serve ``library/index.html``; anything
|
||||||
# Reject path traversal, then fall back to index.html for SPA routes.
|
unknown (or escaping the bundle) gets ``404.html`` with status 404.
|
||||||
if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file():
|
Every ``.html`` page carries this launch's token and is never cached.
|
||||||
candidate = WEB_DIST / _INDEX
|
"""
|
||||||
|
status = HTTPStatus.OK
|
||||||
|
candidate = _resolve_static(path)
|
||||||
|
if candidate is None:
|
||||||
|
status = HTTPStatus.NOT_FOUND
|
||||||
|
candidate = WEB_DIST / _NOT_FOUND_PAGE
|
||||||
if not candidate.is_file():
|
if not candidate.is_file():
|
||||||
send_bytes(self, HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain")
|
send_bytes(self, HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain")
|
||||||
return
|
return
|
||||||
@ -174,11 +180,25 @@ class _Handler(BaseHTTPRequestHandler):
|
|||||||
ctype = f"{ctype}; charset=utf-8"
|
ctype = f"{ctype}; charset=utf-8"
|
||||||
body = candidate.read_bytes()
|
body = candidate.read_bytes()
|
||||||
headers: dict[str, str] = {}
|
headers: dict[str, str] = {}
|
||||||
if candidate.name == _INDEX and candidate.parent == WEB_DIST:
|
if candidate.suffix == ".html":
|
||||||
# Carries this launch's token: never cache it.
|
# Carries this launch's token: never cache it.
|
||||||
body = _web_auth.inject_token(body, self.server.token)
|
body = _web_auth.inject_token(body, self.server.token)
|
||||||
headers["Cache-Control"] = "no-store"
|
headers["Cache-Control"] = "no-store"
|
||||||
send_bytes(self, HTTPStatus.OK, body, ctype, headers)
|
send_bytes(self, status, body, ctype, headers)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_static(path: str) -> Path | None:
|
||||||
|
"""The bundle file for URL *path*, or None when it has none.
|
||||||
|
|
||||||
|
A directory resolves to its ``index.html``; a path escaping ``WEB_DIST``
|
||||||
|
resolves to nothing, never to the file it points at.
|
||||||
|
"""
|
||||||
|
candidate = (WEB_DIST / path.lstrip("/")).resolve()
|
||||||
|
if not candidate.is_relative_to(WEB_DIST):
|
||||||
|
return None
|
||||||
|
if candidate.is_dir():
|
||||||
|
candidate /= _INDEX
|
||||||
|
return candidate if candidate.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
# Content types that are text but not under the ``text/`` prefix.
|
# Content types that are text but not under the ``text/`` prefix.
|
||||||
|
|||||||
@ -100,7 +100,7 @@ def _block_real_subprocesses() -> Iterator[None]:
|
|||||||
"steam_backlog_enforcer._fast_install.subprocess.Popen",
|
"steam_backlog_enforcer._fast_install.subprocess.Popen",
|
||||||
noop_popen,
|
noop_popen,
|
||||||
),
|
),
|
||||||
# A real `npm run build` here would rewrite web/dist/index.html, which
|
# A real `pnpm build` here would rewrite web/dist/index.html, which
|
||||||
# then perturbs frontend_is_stale() for every later test in the run.
|
# then perturbs frontend_is_stale() for every later test in the run.
|
||||||
patch(
|
patch(
|
||||||
"steam_backlog_enforcer._web_build.subprocess.run",
|
"steam_backlog_enforcer._web_build.subprocess.run",
|
||||||
|
|||||||
@ -32,7 +32,7 @@ def web(tmp_path: Path) -> Iterator[Path]:
|
|||||||
with (
|
with (
|
||||||
patch.object(_web_build, "WEB_DIR", root),
|
patch.object(_web_build, "WEB_DIR", root),
|
||||||
patch.object(_web_build, "_BUILT_MARKER", root / "dist" / "index.html"),
|
patch.object(_web_build, "_BUILT_MARKER", root / "dist" / "index.html"),
|
||||||
patch.object(_web_build, "_SOURCE_DIR", root / "src"),
|
patch.object(_web_build, "_SOURCE_DIRS", (root / "src", root / "public")),
|
||||||
patch.object(
|
patch.object(
|
||||||
_web_build,
|
_web_build,
|
||||||
"_SOURCE_FILES",
|
"_SOURCE_FILES",
|
||||||
@ -88,6 +88,12 @@ class TestFrontendIsStale:
|
|||||||
_touch(web / "src" / "nested" / "app.tsx", 2000.0)
|
_touch(web / "src" / "nested" / "app.tsx", 2000.0)
|
||||||
assert _web_build.frontend_is_stale() is True
|
assert _web_build.frontend_is_stale() is True
|
||||||
|
|
||||||
|
def test_newer_public_asset_is_stale(self, web: Path) -> None:
|
||||||
|
# public/ is copied verbatim into dist/: a new favicon needs a build.
|
||||||
|
_touch(web / "dist" / "index.html", 1000.0)
|
||||||
|
_touch(web / "public" / "favicon.svg", 2000.0)
|
||||||
|
assert _web_build.frontend_is_stale() is True
|
||||||
|
|
||||||
def test_all_sources_older_is_current(self, web: Path) -> None:
|
def test_all_sources_older_is_current(self, web: Path) -> None:
|
||||||
_touch(web / "package.json", 1000.0)
|
_touch(web / "package.json", 1000.0)
|
||||||
_touch(web / "src" / "app.tsx", 1000.0)
|
_touch(web / "src" / "app.tsx", 1000.0)
|
||||||
@ -105,36 +111,52 @@ class TestFrontendIsStale:
|
|||||||
def test_missing_src_dir_still_checks_flat_sources(self, web: Path) -> None:
|
def test_missing_src_dir_still_checks_flat_sources(self, web: Path) -> None:
|
||||||
_touch(web / "dist" / "index.html", 1000.0)
|
_touch(web / "dist" / "index.html", 1000.0)
|
||||||
_touch(web / "index.html", 2000.0)
|
_touch(web / "index.html", 2000.0)
|
||||||
with patch.object(_web_build, "_SOURCE_DIR", web / "absent"):
|
with patch.object(_web_build, "_SOURCE_DIRS", (web / "absent",)):
|
||||||
assert _web_build.frontend_is_stale() is True
|
assert _web_build.frontend_is_stale() is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestSourceFiles:
|
||||||
|
"""The watched build inputs match the Astro layout."""
|
||||||
|
|
||||||
|
def test_astro_inputs_are_watched_and_vite_ones_are_gone(self) -> None:
|
||||||
|
names = {path.name for path in _web_build._SOURCE_FILES}
|
||||||
|
assert {
|
||||||
|
"astro.config.mjs",
|
||||||
|
"package.json",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"pnpm-workspace.yaml",
|
||||||
|
} <= names
|
||||||
|
assert {path.name for path in _web_build._SOURCE_DIRS} == {"src", "public"}
|
||||||
|
assert not names & {"vite.config.ts", "tsconfig.app.json", "index.html"}
|
||||||
|
|
||||||
|
|
||||||
class TestBuildFrontend:
|
class TestBuildFrontend:
|
||||||
"""Tests for build_frontend. subprocess is never actually executed."""
|
"""Tests for build_frontend. subprocess is never actually executed."""
|
||||||
|
|
||||||
def test_missing_npm_explains_and_fails(self, web: Path) -> None:
|
def test_missing_pnpm_explains_and_fails(self, web: Path) -> None:
|
||||||
with (
|
with (
|
||||||
patch(f"{_PKG}.shutil.which", return_value=None),
|
patch(f"{_PKG}.shutil.which", return_value=None),
|
||||||
patch(f"{_PKG}._echo") as echo,
|
patch(f"{_PKG}._echo") as echo,
|
||||||
):
|
):
|
||||||
assert _web_build.build_frontend() is False
|
assert _web_build.build_frontend() is False
|
||||||
assert "npm was not found" in echo.call_args[0][0]
|
assert "pnpm was not found" in echo.call_args[0][0]
|
||||||
|
assert "npm install" not in echo.call_args[0][0].replace("pnpm install", "")
|
||||||
|
|
||||||
def test_successful_build_returns_true(self, web: Path) -> None:
|
def test_successful_build_returns_true(self, web: Path) -> None:
|
||||||
run = MagicMock(return_value=MagicMock(returncode=0))
|
run = MagicMock(return_value=MagicMock(returncode=0))
|
||||||
with (
|
with (
|
||||||
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"),
|
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"),
|
||||||
patch(f"{_PKG}.subprocess.run", run),
|
patch(f"{_PKG}.subprocess.run", run),
|
||||||
patch(f"{_PKG}._echo"),
|
patch(f"{_PKG}._echo"),
|
||||||
):
|
):
|
||||||
assert _web_build.build_frontend() is True
|
assert _web_build.build_frontend() is True
|
||||||
assert run.call_args[0][0] == ["/usr/bin/npm", "run", "build"]
|
assert run.call_args[0][0] == ["/usr/bin/pnpm", "build"]
|
||||||
assert run.call_args[1]["cwd"] == web
|
assert run.call_args[1]["cwd"] == web
|
||||||
|
|
||||||
def test_failed_build_prints_output_and_returns_false(self, web: Path) -> None:
|
def test_failed_build_prints_output_and_returns_false(self, web: Path) -> None:
|
||||||
result = MagicMock(returncode=1, stdout=" out \n", stderr=" err \n")
|
result = MagicMock(returncode=1, stdout=" out \n", stderr=" err \n")
|
||||||
with (
|
with (
|
||||||
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"),
|
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"),
|
||||||
patch(f"{_PKG}.subprocess.run", return_value=result),
|
patch(f"{_PKG}.subprocess.run", return_value=result),
|
||||||
patch(f"{_PKG}._echo") as echo,
|
patch(f"{_PKG}._echo") as echo,
|
||||||
):
|
):
|
||||||
@ -147,7 +169,7 @@ class TestBuildFrontend:
|
|||||||
def test_timeout_propagates(self, web: Path) -> None:
|
def test_timeout_propagates(self, web: Path) -> None:
|
||||||
# A hung build must not be swallowed into "bundle is fine".
|
# A hung build must not be swallowed into "bundle is fine".
|
||||||
with (
|
with (
|
||||||
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/npm"),
|
patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"),
|
||||||
patch(
|
patch(
|
||||||
f"{_PKG}.subprocess.run",
|
f"{_PKG}.subprocess.run",
|
||||||
side_effect=subprocess.TimeoutExpired("npm", 1),
|
side_effect=subprocess.TimeoutExpired("npm", 1),
|
||||||
|
|||||||
@ -127,7 +127,7 @@ class TestDatasetEndpoint:
|
|||||||
|
|
||||||
|
|
||||||
class TestStaticServing:
|
class TestStaticServing:
|
||||||
"""Tests for static-file serving + SPA fallback + traversal guard."""
|
"""Tests for static-file serving, the 404 page and the traversal guard."""
|
||||||
|
|
||||||
def test_serves_index(self, tmp_path: Path) -> None:
|
def test_serves_index(self, tmp_path: Path) -> None:
|
||||||
dist = _make_dist(tmp_path)
|
dist = _make_dist(tmp_path)
|
||||||
@ -152,19 +152,19 @@ class TestStaticServing:
|
|||||||
assert "image/png" in ctype
|
assert "image/png" in ctype
|
||||||
assert "charset" not in ctype
|
assert "charset" not in ctype
|
||||||
|
|
||||||
def test_spa_fallback_to_index(self, tmp_path: Path) -> None:
|
def test_unknown_route_gets_the_404_page(self, tmp_path: Path) -> None:
|
||||||
dist = _make_dist(tmp_path)
|
dist = _make_dist(tmp_path, files={"404.html": b"<html>MISSING</html>"})
|
||||||
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
|
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
|
||||||
status, body, _ = _get(port, "/some/spa/route")
|
status, body, _ = _get(port, "/some/unknown/route")
|
||||||
assert status == 200
|
assert status == 404
|
||||||
assert b"INDEX" in body
|
assert b"MISSING" in body
|
||||||
|
|
||||||
def test_path_traversal_blocked(self, tmp_path: Path) -> None:
|
def test_path_traversal_blocked(self, tmp_path: Path) -> None:
|
||||||
dist = _make_dist(tmp_path)
|
dist = _make_dist(tmp_path, files={"404.html": b"<html>MISSING</html>"})
|
||||||
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
|
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
|
||||||
status, body, _ = _get(port, "/../../../../../../etc/passwd")
|
status, body, _ = _get(port, "/../../../../../../etc/passwd")
|
||||||
assert status == 200
|
assert status == 404
|
||||||
assert b"INDEX" in body # fell back to index, did not serve the secret
|
assert b"MISSING" in body # got the 404 page, not the secret
|
||||||
assert b"root:" not in body
|
assert b"root:" not in body
|
||||||
|
|
||||||
def test_not_built_returns_404(self, tmp_path: Path) -> None:
|
def test_not_built_returns_404(self, tmp_path: Path) -> None:
|
||||||
|
|||||||
@ -40,7 +40,9 @@ def dist(tmp_path: Path) -> Iterator[Path]:
|
|||||||
(root / "noext").write_text("plain", "utf-8")
|
(root / "noext").write_text("plain", "utf-8")
|
||||||
sub = root / "sub"
|
sub = root / "sub"
|
||||||
sub.mkdir()
|
sub.mkdir()
|
||||||
(sub / "index.html").write_text("<html>SUB</html>", "utf-8")
|
(sub / "index.html").write_text("<html><head></head>SUB</html>", "utf-8")
|
||||||
|
(sub / "page.html").write_text("<html>PAGE</html>", "utf-8")
|
||||||
|
(root / "404.html").write_text("<html><head></head>MISSING</html>", "utf-8")
|
||||||
(tmp_path / "secret.txt").write_text("top secret", "utf-8")
|
(tmp_path / "secret.txt").write_text("top secret", "utf-8")
|
||||||
with patch(f"{_PKG}.WEB_DIST", root):
|
with patch(f"{_PKG}.WEB_DIST", root):
|
||||||
yield root
|
yield root
|
||||||
@ -93,19 +95,36 @@ class TestStatic:
|
|||||||
def test_unknown_extension_is_plain_text(self) -> None:
|
def test_unknown_extension_is_plain_text(self) -> None:
|
||||||
assert _get("/noext")[2] == "text/plain; charset=utf-8"
|
assert _get("/noext")[2] == "text/plain; charset=utf-8"
|
||||||
|
|
||||||
def test_a_nested_index_is_served_without_a_token(self) -> None:
|
@pytest.mark.parametrize("path", ["/sub", "/sub/", "/sub/index.html", "/sub/?id=7"])
|
||||||
_, body, _, cache, _ = _get("/sub/index.html")
|
def test_a_directory_serves_its_own_index_with_a_token(self, path: str) -> None:
|
||||||
assert (body, cache) == (b"<html>SUB</html>", "")
|
status, body, ctype, cache, token = _get(path)
|
||||||
|
assert (status, b"SUB" in body) == (200, True)
|
||||||
|
assert f'content="{token}"'.encode() in body
|
||||||
|
assert (ctype, cache) == ("text/html; charset=utf-8", "no-store")
|
||||||
|
|
||||||
def test_spa_route_falls_back_to_index(self) -> None:
|
def test_every_html_file_carries_the_token_and_is_not_cached(self) -> None:
|
||||||
status, body, _, _, _ = _get("/library/42")
|
status, body, _, cache, token = _get("/sub/page.html")
|
||||||
assert (status, b"INDEX" in body) == (200, True)
|
assert (status, cache) == (200, "no-store")
|
||||||
|
assert f'content="{token}"'.encode() in body
|
||||||
|
|
||||||
def test_path_traversal_gets_the_index_not_the_file(self) -> None:
|
def test_unknown_route_is_the_404_page_with_a_token(self) -> None:
|
||||||
|
status, body, _, cache, token = _get("/library/42")
|
||||||
|
assert (status, b"MISSING" in body, cache) == (404, True, "no-store")
|
||||||
|
assert f'content="{token}"'.encode() in body
|
||||||
|
|
||||||
|
def test_path_traversal_gets_the_404_page_not_the_file(self) -> None:
|
||||||
status, body, _, _, _ = _get("/..%2Fsecret.txt")
|
status, body, _, _, _ = _get("/..%2Fsecret.txt")
|
||||||
assert status == 200
|
assert (status, b"MISSING" in body) == (404, True)
|
||||||
assert b"top secret" not in body
|
assert b"top secret" not in body
|
||||||
|
|
||||||
|
def test_unknown_route_without_a_404_page_is_not_built(self, dist: Path) -> None:
|
||||||
|
(dist / "404.html").unlink()
|
||||||
|
status, body, _, _, _ = _get("/nope")
|
||||||
|
assert (status, body) == (404, _web_server._NOT_BUILT_MSG)
|
||||||
|
|
||||||
|
def test_not_built_message_names_pnpm(self) -> None:
|
||||||
|
assert b"pnpm install && pnpm build" in _web_server._NOT_BUILT_MSG
|
||||||
|
|
||||||
def test_unbuilt_frontend_is_a_404(self, tmp_path: Path) -> None:
|
def test_unbuilt_frontend_is_a_404(self, tmp_path: Path) -> None:
|
||||||
with patch(f"{_PKG}.WEB_DIST", (tmp_path / "missing").resolve()):
|
with patch(f"{_PKG}.WEB_DIST", (tmp_path / "missing").resolve()):
|
||||||
status, body, _, _, _ = _get("/")
|
status, body, _, _, _ = _get("/")
|
||||||
|
|||||||
4
web/.gitignore
vendored
4
web/.gitignore
vendored
@ -22,3 +22,7 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Astro generated types
|
||||||
|
.astro
|
||||||
|
coverage
|
||||||
|
|||||||
@ -1,73 +1,7 @@
|
|||||||
# React + TypeScript + Vite
|
# web
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
The loopback control UI: Astro static pages (one per section) with vanilla
|
||||||
|
TypeScript custom elements, served from `dist/` by `steam_backlog_enforcer._web_server`.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
`pnpm dev` (proxies `/api` to `./run.sh serve`), `pnpm dev:mock` (in-memory API),
|
||||||
|
`pnpm test`, `pnpm coverage`, `pnpm lint`, `pnpm build`.
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|||||||
33
web/astro.config.mjs
Normal file
33
web/astro.config.mjs
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { defineConfig } from 'astro/config'
|
||||||
|
|
||||||
|
// Static pages, one directory per section (`dist/library/index.html`), served
|
||||||
|
// by steam_backlog_enforcer._web_server, which injects the per-launch token
|
||||||
|
// into every page. In dev, `/api` is proxied to the Python server (SBE_PORT,
|
||||||
|
// default 8000) presenting itself as that server's own origin so its
|
||||||
|
// DNS-rebinding and origin checks pass; `--mode mock` (pnpm dev:mock) swaps
|
||||||
|
// the proxy for the in-memory contract mock in mock/. Both are dev-only:
|
||||||
|
// nothing here reaches `astro build` output.
|
||||||
|
const apiPort = process.env.SBE_PORT ?? '8000'
|
||||||
|
const apiOrigin = `http://127.0.0.1:${apiPort}`
|
||||||
|
const mode = process.argv.includes('--mode') ? process.argv[process.argv.indexOf('--mode') + 1] : ''
|
||||||
|
|
||||||
|
// The dev token meta comes from src/components/DevToken.astro (dev only).
|
||||||
|
async function devPlugins() {
|
||||||
|
if (mode !== 'mock') return []
|
||||||
|
const { mockApi } = await import('./mock/plugin.ts')
|
||||||
|
return [mockApi()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
output: 'static',
|
||||||
|
build: { format: 'directory', assets: 'assets' },
|
||||||
|
trailingSlash: 'ignore',
|
||||||
|
devToolbar: { enabled: false },
|
||||||
|
vite: {
|
||||||
|
plugins: process.argv.includes('dev') ? await devPlugins() : [],
|
||||||
|
server:
|
||||||
|
mode === 'mock'
|
||||||
|
? {}
|
||||||
|
: { proxy: { '/api': { target: apiOrigin, changeOrigin: true, headers: { Origin: apiOrigin } } } },
|
||||||
|
},
|
||||||
|
})
|
||||||
@ -1,30 +1,37 @@
|
|||||||
import js from '@eslint/js'
|
import js from '@eslint/js'
|
||||||
|
import astro from 'eslint-plugin-astro'
|
||||||
import globals from 'globals'
|
import globals from 'globals'
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
import tseslint from 'typescript-eslint'
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
// typescript-eslint strict + stylistic (type-checked) on .ts; .astro files get
|
||||||
globalIgnores(['dist', 'coverage']),
|
// eslint-plugin-astro's recommended set (their scripts also pass astro check).
|
||||||
{
|
export default tseslint.config(
|
||||||
files: ['**/*.{ts,tsx}'],
|
{ ignores: ['dist', 'coverage', '.astro', 'src/env.d.ts'] },
|
||||||
extends: [
|
{ linterOptions: { reportUnusedDisableDirectives: 'error' } },
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
...[...tseslint.configs.strictTypeChecked, ...tseslint.configs.stylisticTypeChecked].map((c) => ({
|
||||||
// `recommended` includes the React Compiler rules since plugin v6.
|
...c,
|
||||||
reactHooks.configs.flat.recommended,
|
files: ['**/*.ts'],
|
||||||
reactRefresh.configs.vite,
|
})),
|
||||||
],
|
...astro.configs['flat/recommended'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
|
parserOptions: { project: './tsconfig.json', tsconfigRootDir: import.meta.dirname },
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/restrict-template-expressions': ['error', { allowNumber: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// The dev-only mock API and the Vite config run in Node, not the browser.
|
files: ['**/*.test.ts', 'src/test/**'],
|
||||||
files: ['mock/**/*.ts', 'vite.config.ts'],
|
rules: { '@typescript-eslint/unbound-method': 'off' },
|
||||||
languageOptions: {
|
|
||||||
globals: globals.node,
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The dev-only mock API and the configs run in Node, not the browser.
|
||||||
|
files: ['mock/**/*.ts', '*.js', '*.mjs', 'vitest.config.ts'],
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
},
|
},
|
||||||
])
|
{ files: ['*.js', '*.mjs'], ...tseslint.configs.disableTypeChecked },
|
||||||
|
)
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Steam Backlog Enforcer</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,20 +1,24 @@
|
|||||||
// Dev-only Vite plugin that serves the whole web-control API contract from
|
// Dev-only Vite plugin that serves the whole web-control API contract from
|
||||||
// memory (`npm run dev:mock`). `apply: 'serve'` plus the mode gate in
|
// memory (`pnpm dev:mock`). `apply: 'serve'` plus the mode gate in
|
||||||
// vite.config.ts keep it out of every production build.
|
// astro.config.mjs keep it out of every production build.
|
||||||
|
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import type { Plugin } from 'vite'
|
import type { AstroUserConfig } from 'astro'
|
||||||
import { TOKEN_META } from '../src/api/contract.ts'
|
|
||||||
import { handle } from './routes.ts'
|
import { handle } from './routes.ts'
|
||||||
|
|
||||||
|
// Vite is Astro's, not a direct dependency: take its Plugin type from there.
|
||||||
|
type PluginOption = NonNullable<NonNullable<AstroUserConfig['vite']>['plugins']>[number]
|
||||||
|
type Plugin = Extract<PluginOption, { name: string }>
|
||||||
|
|
||||||
export function mockApi(): Plugin {
|
export function mockApi(): Plugin {
|
||||||
// Per-launch token, like the real server's web-<port>.token.
|
// Per-launch token, like the real server's web-<port>.token.
|
||||||
const token = randomBytes(32).toString('hex')
|
const token = randomBytes(32).toString('hex')
|
||||||
return {
|
return {
|
||||||
name: 'sbe-mock-api',
|
name: 'sbe-mock-api',
|
||||||
apply: 'serve',
|
apply: 'serve',
|
||||||
transformIndexHtml() {
|
// src/components/DevToken.astro renders this into every page in dev.
|
||||||
return [{ tag: 'meta', attrs: { name: TOKEN_META, content: token }, injectTo: 'head' }]
|
config() {
|
||||||
|
process.env.SBE_DEV_TOKEN = token
|
||||||
},
|
},
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
server.middlewares.use((req, res, next) => {
|
server.middlewares.use((req, res, next) => {
|
||||||
|
|||||||
4556
web/package-lock.json
generated
4556
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -3,44 +3,39 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"packageManager": "pnpm@12.18.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=24",
|
||||||
|
"pnpm": ">=10"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "astro dev",
|
||||||
"dev:mock": "vite --mode mock",
|
"dev:mock": "astro dev --mode mock",
|
||||||
"build": "tsc -b && vite build",
|
"build": "astro check && astro build",
|
||||||
"lint": "eslint .",
|
"lint": "astro check && eslint src",
|
||||||
"preview": "vite preview",
|
"preview": "astro preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.107.1",
|
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.5.0&path:/web_ui",
|
||||||
"@tanstack/react-router": "1.170.46",
|
"astro": "7.4.7"
|
||||||
"react": "19.3.1",
|
|
||||||
"react-dom": "19.3.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "8.1.0",
|
"@astrojs/check": "0.9.12",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@rolldown/plugin-babel": "0.3.0",
|
|
||||||
"@testing-library/dom": "10.4.2",
|
"@testing-library/dom": "10.4.2",
|
||||||
"@testing-library/jest-dom": "7.0.1",
|
"@testing-library/jest-dom": "7.0.1",
|
||||||
"@testing-library/react": "16.3.3",
|
|
||||||
"@testing-library/user-event": "14.6.8",
|
"@testing-library/user-event": "14.6.8",
|
||||||
"@types/node": "26.6.6",
|
"@types/node": "26.6.6",
|
||||||
"@types/react": "19.3.0",
|
|
||||||
"@types/react-dom": "19.3.0",
|
|
||||||
"@vitejs/plugin-react": "6.2.1",
|
|
||||||
"@vitest/coverage-v8": "5.1.0",
|
"@vitest/coverage-v8": "5.1.0",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
|
||||||
"eslint": "10.14.0",
|
"eslint": "10.14.0",
|
||||||
"eslint-plugin-react-hooks": "7.1.1",
|
"eslint-plugin-astro": "3.2.1",
|
||||||
"eslint-plugin-react-refresh": "0.5.7",
|
|
||||||
"globals": "17.14.0",
|
"globals": "17.14.0",
|
||||||
"jsdom": "31.0.0",
|
"jsdom": "31.0.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.72.1",
|
"typescript-eslint": "8.72.1",
|
||||||
"vite": "8.3.6",
|
|
||||||
"vitest": "5.1.0"
|
"vitest": "5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3716
web/pnpm-lock.yaml
3716
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
13
web/pnpm-workspace.yaml
Normal file
13
web/pnpm-workspace.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# pnpm build-script allowlist: esbuild links its platform binary in a
|
||||||
|
# postinstall (Vite, under Astro, depends on it).
|
||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
sharp: false
|
||||||
|
minimumReleaseAgeExclude:
|
||||||
|
- astro@7.4.7
|
||||||
|
# A peer range admits @emnapi 2.0.0-beta.2, whose runtime crashes
|
||||||
|
# @astrojs/check on exit ("bridge.deleteEnv is not a function"); hold the
|
||||||
|
# newest stable 1.x until 2.0 is final.
|
||||||
|
overrides:
|
||||||
|
'@emnapi/core': 1.11.3
|
||||||
|
'@emnapi/runtime': 1.11.3
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import App from './App'
|
|
||||||
import { refreshAfterJob } from './api/queries'
|
|
||||||
import { hub } from './jobs/hub'
|
|
||||||
import { router } from './router'
|
|
||||||
import { makeBudget, makeDataset } from './test/factories'
|
|
||||||
import { makeInstalled, makeJob, makeLibGame, makeSpec, makeStatus } from './test/fixtures'
|
|
||||||
import { installEventSource, stubApi } from './test/harness'
|
|
||||||
|
|
||||||
const routes = (over: Record<string, unknown> = {}) => ({
|
|
||||||
'GET /api/setup': { configured: true, has_api_key: true, steam_id: '76561198000000000' },
|
|
||||||
'GET /api/server': { stale: false, started_at: '2026-10-10T10:00:00Z', version: '1' },
|
|
||||||
'GET /api/daemon': { state: 'running', started_at: null, pid: 1, journal_tail: [], restart_available_at: null },
|
|
||||||
'GET /api/status': makeStatus(),
|
|
||||||
'GET /api/budget': makeBudget(),
|
|
||||||
'GET /api/dataset': makeDataset(),
|
|
||||||
'GET /api/stats': { default_summary: makeDataset().default_summary, pace_vs_hltb: null },
|
|
||||||
'GET /api/installed': { games: [makeInstalled()] },
|
|
||||||
'GET /api/library': { games: [makeLibGame()] },
|
|
||||||
'GET /api/backups': [],
|
|
||||||
'GET /api/jobs': [],
|
|
||||||
'GET /api/jobs/abc': makeJob({ id: 'abc', command: 'scan' }),
|
|
||||||
'GET /api/commands': [makeSpec('scan'), makeSpec('done')],
|
|
||||||
...over,
|
|
||||||
})
|
|
||||||
|
|
||||||
// The router only follows history while a RouterProvider is mounted, so
|
|
||||||
// awaiting router.navigate() before render would never settle: set the URL,
|
|
||||||
// mount, then have the router read it.
|
|
||||||
async function openAt(path: string) {
|
|
||||||
router.history.replace(path)
|
|
||||||
const view = render(<App />)
|
|
||||||
await act(async () => {
|
|
||||||
await router.load()
|
|
||||||
})
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('App', () => {
|
|
||||||
beforeEach(installEventSource)
|
|
||||||
afterEach(() => {
|
|
||||||
router.history.replace('/')
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('refreshes every cached view when any job ends', () => {
|
|
||||||
expect(hub.onTerminal).toBe(refreshAfterJob)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opens on the dashboard inside the shell', async () => {
|
|
||||||
stubApi(routes())
|
|
||||||
await openAt('/')
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Dashboard' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('navigation', { name: 'Sections' })).toBeInTheDocument()
|
|
||||||
expect(await screen.findByText('Hollow Knight')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('walks the sidebar to another section', async () => {
|
|
||||||
stubApi(routes())
|
|
||||||
await openAt('/')
|
|
||||||
await userEvent.click(await screen.findByRole('link', { name: /Store/ }))
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Store' })).toBeInTheDocument()
|
|
||||||
expect(router.state.location.pathname).toBe('/store')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the library at /library', async () => {
|
|
||||||
stubApi(routes())
|
|
||||||
await openAt('/library')
|
|
||||||
expect(await screen.findByText('1 of 1 games')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('/gaming search', () => {
|
|
||||||
const modes: [string, string, string][] = [
|
|
||||||
['true', '?demo=true', 'Show production budget'],
|
|
||||||
['1', '?demo=1', 'Show production budget'],
|
|
||||||
['"1"', '?demo=%221%22', 'Show production budget'],
|
|
||||||
['anything else', '?demo=maybe', 'Show demo budget'],
|
|
||||||
['absent', '', 'Show demo budget'],
|
|
||||||
]
|
|
||||||
it.each(modes)('demo=%s', async (_label, query, link) => {
|
|
||||||
stubApi(routes({ 'GET /api/budget?demo=1': makeBudget() }))
|
|
||||||
await openAt(`/gaming${query}`)
|
|
||||||
expect(await screen.findByRole('link', { name: link })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('routes /jobs/$jobId to the job’s page', async () => {
|
|
||||||
stubApi(routes())
|
|
||||||
await openAt('/jobs/abc')
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Scan', level: 1 })).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('abc')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the not-found screen inside the shell', async () => {
|
|
||||||
stubApi(routes())
|
|
||||||
await openAt('/nope')
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Not found' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('navigation', { name: 'Sections' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('redirects an unconfigured install to Setup', async () => {
|
|
||||||
stubApi(routes({ 'GET /api/setup': { configured: false, has_api_key: false, steam_id: null } }))
|
|
||||||
await openAt('/picks')
|
|
||||||
await waitFor(() => expect(router.state.location.pathname).toBe('/setup'))
|
|
||||||
expect(await screen.findByText('Not configured yet')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { RouterProvider } from '@tanstack/react-router'
|
|
||||||
import { queryClient, refreshAfterJob } from './api/queries'
|
|
||||||
import { hub } from './jobs/hub'
|
|
||||||
import { router } from './router'
|
|
||||||
|
|
||||||
// A finished job can change anything (assignment, installed list, budget),
|
|
||||||
// so every cached view is refetched when one ends.
|
|
||||||
hub.onTerminal = refreshAfterJob
|
|
||||||
|
|
||||||
/** The control UI: every `run.sh` command, routed by section. */
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</QueryClientProvider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@ -1,11 +1,13 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { TOKEN_META } from './contract'
|
import { TOKEN_META } from './contract'
|
||||||
import { api, ApiFailure, request, sessionToken, startJob } from './client'
|
import { api, ApiFailure, request, sessionToken, startJob } from './client'
|
||||||
import { apiError, bodiesOf, Reply, stubApi } from '../test/harness'
|
import { apiError, bodiesOf, Reply, stubApi, type Handler } from '../test/harness'
|
||||||
import { makeJob, makePending } from '../test/fixtures'
|
import { makeJob, makePending } from '../test/fixtures'
|
||||||
|
|
||||||
function setToken(token: string | null) {
|
function setToken(token: string | null) {
|
||||||
document.head.querySelectorAll(`meta[name="${TOKEN_META}"]`).forEach((m) => m.remove())
|
document.head.querySelectorAll(`meta[name="${TOKEN_META}"]`).forEach((m) => {
|
||||||
|
m.remove()
|
||||||
|
})
|
||||||
if (token !== null) {
|
if (token !== null) {
|
||||||
const m = document.createElement('meta')
|
const m = document.createElement('meta')
|
||||||
m.name = TOKEN_META
|
m.name = TOKEN_META
|
||||||
@ -15,7 +17,9 @@ function setToken(token: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('sessionToken', () => {
|
describe('sessionToken', () => {
|
||||||
afterEach(() => setToken(null))
|
afterEach(() => {
|
||||||
|
setToken(null)
|
||||||
|
})
|
||||||
it('reads the meta tag, empty when absent', () => {
|
it('reads the meta tag, empty when absent', () => {
|
||||||
expect(sessionToken()).toBe('')
|
expect(sessionToken()).toBe('')
|
||||||
setToken('abc')
|
setToken('abc')
|
||||||
@ -24,7 +28,9 @@ describe('sessionToken', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('request', () => {
|
describe('request', () => {
|
||||||
beforeEach(() => setToken('tok'))
|
beforeEach(() => {
|
||||||
|
setToken('tok')
|
||||||
|
})
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
@ -76,7 +82,7 @@ describe('request', () => {
|
|||||||
const reload = vi.fn()
|
const reload = vi.fn()
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
reload.mockClear()
|
reload.mockClear()
|
||||||
vi.stubGlobal('location', { ...window.location, reload })
|
vi.stubGlobal('location', { href: window.location.href, reload })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reloads once, then not again inside the guard window', async () => {
|
it('reloads once, then not again inside the guard window', async () => {
|
||||||
@ -107,7 +113,7 @@ describe('api', () => {
|
|||||||
afterEach(() => vi.unstubAllGlobals())
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
it('hits every endpoint with the right verb and path', async () => {
|
it('hits every endpoint with the right verb and path', async () => {
|
||||||
const routes: Record<string, unknown> = {}
|
const routes: Record<string, Handler> = {}
|
||||||
const keys = [
|
const keys = [
|
||||||
'GET /api/status', 'GET /api/stats', 'GET /api/installed', 'GET /api/library', 'GET /api/commands',
|
'GET /api/status', 'GET /api/stats', 'GET /api/installed', 'GET /api/library', 'GET /api/commands',
|
||||||
'GET /api/setup', 'POST /api/setup', 'GET /api/daemon', 'GET /api/server', 'POST /api/server/restart',
|
'GET /api/setup', 'POST /api/setup', 'GET /api/daemon', 'GET /api/server', 'POST /api/server/restart',
|
||||||
|
|||||||
@ -103,20 +103,20 @@ export const api = {
|
|||||||
saveSetup: (req: SetupRequest) => request<SetupStatus>('POST', ENDPOINTS.setup, req),
|
saveSetup: (req: SetupRequest) => request<SetupStatus>('POST', ENDPOINTS.setup, req),
|
||||||
daemon: () => get<DaemonStatus>(ENDPOINTS.daemon),
|
daemon: () => get<DaemonStatus>(ENDPOINTS.daemon),
|
||||||
server: () => get<ServerHealth>(ENDPOINTS.server),
|
server: () => get<ServerHealth>(ENDPOINTS.server),
|
||||||
restartServer: () => request<void>('POST', ENDPOINTS.serverRestart),
|
restartServer: () => request<undefined>('POST', ENDPOINTS.serverRestart),
|
||||||
backups: () => get<StateBackup[]>(ENDPOINTS.backups),
|
backups: () => get<StateBackup[]>(ENDPOINTS.backups),
|
||||||
restoreBackup: (id: string, phrase: string) =>
|
restoreBackup: (id: string, phrase: string) =>
|
||||||
request<Job>('POST', ENDPOINTS.backupRestore(encodeURIComponent(id)), { confirm_phrase: phrase }),
|
request<Job>('POST', ENDPOINTS.backupRestore(encodeURIComponent(id)), { confirm_phrase: phrase }),
|
||||||
jobs: () => get<Job[]>(ENDPOINTS.jobs),
|
jobs: () => get<Job[]>(ENDPOINTS.jobs),
|
||||||
job: (id: string) => get<Job>(ENDPOINTS.job(id)),
|
job: (id: string) => get<Job>(ENDPOINTS.job(id)),
|
||||||
answer: (id: string, answer: PromptAnswer) => request<void>('POST', ENDPOINTS.jobAnswer(id), answer),
|
answer: (id: string, answer: PromptAnswer) => request<undefined>('POST', ENDPOINTS.jobAnswer(id), answer),
|
||||||
cancelJob: (id: string) => request<void>('POST', ENDPOINTS.jobCancel(id)),
|
cancelJob: (id: string) => request<undefined>('POST', ENDPOINTS.jobCancel(id)),
|
||||||
heartbeat: (id: string) => request<PendingAction>('POST', ENDPOINTS.pendingHeartbeat(id)),
|
heartbeat: (id: string) => request<PendingAction>('POST', ENDPOINTS.pendingHeartbeat(id)),
|
||||||
commit: (id: string, phrase: string) =>
|
commit: (id: string, phrase: string) =>
|
||||||
request<Job>('POST', ENDPOINTS.pendingCommit(id), { confirm_phrase: phrase }),
|
request<Job>('POST', ENDPOINTS.pendingCommit(id), { confirm_phrase: phrase }),
|
||||||
/** `keepalive` lets the DELETE survive the page being closed. */
|
/** `keepalive` lets the DELETE survive the page being closed. */
|
||||||
dropPending: (id: string, keepalive = false) =>
|
dropPending: (id: string, keepalive = false) =>
|
||||||
request<void>('DELETE', ENDPOINTS.pending(id), undefined, keepalive),
|
request<undefined>('DELETE', ENDPOINTS.pending(id), undefined, keepalive),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/jobs: 201 → Job, 202 → PendingAction (countdown commands). */
|
/** POST /api/jobs: 201 → Job, 202 → PendingAction (countdown commands). */
|
||||||
|
|||||||
@ -1,59 +1,138 @@
|
|||||||
import { QueryObserver } from '@tanstack/react-query'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { makeBudget, makeDataset } from '../test/factories'
|
import { makeBudget, makeDataset } from '../test/factories'
|
||||||
import { makeJob } from '../test/fixtures'
|
import { makeJob } from '../test/fixtures'
|
||||||
import { stubApi } from '../test/harness'
|
import { apiError, Reply, stubApi, type Handler } from '../test/harness'
|
||||||
import { isActive, q, queryClient, refreshAfterJob } from './queries'
|
import { isActive, q, refreshAfterJob } from './queries'
|
||||||
|
|
||||||
type Interval = number | false | undefined | ((query: never) => number | false | undefined)
|
|
||||||
const intervalOf = (o: { refetchInterval?: unknown }, data: unknown): unknown => {
|
|
||||||
const f = o.refetchInterval as Interval
|
|
||||||
return typeof f === 'function' ? (f as (x: unknown) => unknown)({ state: { data } }) : f
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('queries', () => {
|
describe('queries', () => {
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('isActive is false only for terminal states', () => {
|
it('isActive is false only for terminal states', () => {
|
||||||
expect(isActive(makeJob({ state: 'running' }))).toBe(true)
|
expect(isActive(makeJob({ state: 'running' }))).toBe(true)
|
||||||
expect(isActive(makeJob({ state: 'waiting_input' }))).toBe(true)
|
expect(isActive(makeJob({ state: 'waiting_input' }))).toBe(true)
|
||||||
expect(isActive(makeJob({ state: 'succeeded' }))).toBe(false)
|
expect(isActive(makeJob({ state: 'succeeded' }))).toBe(false)
|
||||||
|
expect(isActive(makeJob({ state: 'failed' }))).toBe(false)
|
||||||
|
expect(isActive(makeJob({ state: 'cancelled' }))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('polls jobs fast while one is active, slowly otherwise', () => {
|
it('every query reaches its endpoint', async () => {
|
||||||
expect(intervalOf(q.jobs, [makeJob({ state: 'running' })])).toBe(2_000)
|
|
||||||
expect(intervalOf(q.jobs, [makeJob({ state: 'failed' })])).toBe(15_000)
|
|
||||||
expect(intervalOf(q.jobs, undefined)).toBe(15_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('polls one job until it ends', () => {
|
|
||||||
const o = q.job('j1')
|
|
||||||
expect(intervalOf(o, undefined)).toBe(2_000)
|
|
||||||
expect(intervalOf(o, makeJob({ state: 'running' }))).toBe(2_000)
|
|
||||||
expect(intervalOf(o, makeJob({ state: 'succeeded' }))).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('every query function reaches its endpoint', async () => {
|
|
||||||
const mock = stubApi({
|
const mock = stubApi({
|
||||||
'GET /api/status': {}, 'GET /api/stats': {}, 'GET /api/installed': {}, 'GET /api/library': {},
|
'GET /api/status': {}, 'GET /api/stats': {}, 'GET /api/installed': {}, 'GET /api/library': {},
|
||||||
'GET /api/commands': [], 'GET /api/setup': {}, 'GET /api/daemon': {}, 'GET /api/server': {},
|
'GET /api/commands': [], 'GET /api/setup': {}, 'GET /api/daemon': {}, 'GET /api/server': {},
|
||||||
'GET /api/backups': [], 'GET /api/dataset': makeDataset(), 'GET /api/budget': makeBudget(),
|
'GET /api/backups': [], 'GET /api/dataset': makeDataset(), 'GET /api/budget': makeBudget(),
|
||||||
'GET /api/jobs': [], 'GET /api/jobs/j1': makeJob(),
|
'GET /api/budget?demo=1': makeBudget(), 'GET /api/jobs': [], 'GET /api/jobs/j1': makeJob(),
|
||||||
})
|
})
|
||||||
const all = [q.status, q.stats, q.installed, q.library, q.commands, q.setup, q.daemon, q.server,
|
const all = [q.status, q.stats, q.installed, q.library, q.commands, q.setup, q.daemon, q.server,
|
||||||
q.backups, q.dataset, q.budget, q.jobs, q.job('j1')]
|
q.backups, q.dataset, q.budget, q.demoBudget, q.jobs, q.job('j1')]
|
||||||
for (const o of all) await queryClient.fetchQuery({ ...o, retry: false, staleTime: 0 })
|
for (const query of all) await query.refetch()
|
||||||
expect(mock).toHaveBeenCalledTimes(all.length)
|
expect(mock).toHaveBeenCalledTimes(all.length)
|
||||||
|
expect(all.every((query) => query.state.error === null)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('refreshAfterJob invalidates everything', async () => {
|
it('polls jobs fast while one is active, slowly otherwise', async () => {
|
||||||
stubApi({ 'GET /api/server': { stale: false } })
|
const jobs = [makeJob({ state: 'running' })]
|
||||||
const obs = new QueryObserver(queryClient, { ...q.server, refetchInterval: false })
|
const mock = stubApi({ 'GET /api/jobs': () => jobs })
|
||||||
const unsub = obs.subscribe(() => {})
|
q.jobs.subscribe(() => undefined)
|
||||||
await vi.waitFor(() => expect(obs.getCurrentResult().isSuccess).toBe(true))
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
const spy = vi.spyOn(queryClient, 'invalidateQueries')
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(2)
|
||||||
|
jobs[0] = makeJob({ state: 'failed' })
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(3)
|
||||||
|
await vi.advanceTimersByTimeAsync(14_000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(3)
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polls jobs slowly while nothing has loaded', async () => {
|
||||||
|
const mock = stubApi({ 'GET /api/jobs': apiError(500, 'op_failed') })
|
||||||
|
q.jobs.subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
await vi.advanceTimersByTimeAsync(14_999)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1)
|
||||||
|
await vi.advanceTimersByTimeAsync(1)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polls one job until it ends', async () => {
|
||||||
|
let job = makeJob({ state: 'running' })
|
||||||
|
const mock = stubApi({ 'GET /api/jobs/j9': () => job })
|
||||||
|
q.job('j9').subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(2)
|
||||||
|
job = makeJob({ state: 'succeeded' })
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(3)
|
||||||
|
await vi.advanceTimersByTimeAsync(60_000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps polling a job whose first answer failed', async () => {
|
||||||
|
const mock = stubApi({ 'GET /api/jobs/j8': apiError(404, 'not_found') })
|
||||||
|
q.job('j8').subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
await vi.advanceTimersByTimeAsync(2000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refreshAfterJob refetches every watched query', async () => {
|
||||||
|
const mock = stubApi({ 'GET /api/server': { stale: false } })
|
||||||
|
q.server.subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
refreshAfterJob()
|
refreshAfterJob()
|
||||||
expect(spy).toHaveBeenCalled()
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
unsub()
|
expect(mock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('budget polling', () => {
|
||||||
|
const setHidden = (hidden: boolean) => {
|
||||||
|
Object.defineProperty(document, 'hidden', { value: hidden, configurable: true })
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
setHidden(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polls the budget every 5 s, the demo run from its own URL', async () => {
|
||||||
|
const mock = stubApi({ 'GET /api/budget': makeBudget(), 'GET /api/budget?demo=1': makeBudget() })
|
||||||
|
q.budget.subscribe(() => undefined)
|
||||||
|
q.demoBudget.subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
expect(mock.mock.calls.map(([p]) => p)).toEqual(['/api/budget', '/api/budget?demo=1'])
|
||||||
|
await vi.advanceTimersByTimeAsync(5000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(4)
|
||||||
|
expect(q.budget.state.data?.today?.gaming_day).toBe('2026-08-28')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('polls while the tab is hidden', async () => {
|
||||||
|
setHidden(true)
|
||||||
|
const mock = stubApi({ 'GET /api/budget': makeBudget() })
|
||||||
|
q.budget.subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the last snapshot when a poll fails', async () => {
|
||||||
|
const routes: Record<string, Handler> = { 'GET /api/budget': makeBudget() }
|
||||||
|
stubApi(routes)
|
||||||
|
q.budget.subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
routes['GET /api/budget'] = new Reply(500, undefined, 'oops')
|
||||||
|
await vi.advanceTimersByTimeAsync(5000)
|
||||||
|
expect(q.budget.state.data?.ok).toBe(true)
|
||||||
|
expect(q.budget.state.error).toEqual(new Error('API returned 500 Error'))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops polling after the last subscriber leaves', async () => {
|
||||||
|
const mock = stubApi({ 'GET /api/budget': makeBudget() })
|
||||||
|
const off = q.budget.subscribe(() => undefined)
|
||||||
|
await vi.advanceTimersByTimeAsync(0)
|
||||||
|
off()
|
||||||
|
await vi.advanceTimersByTimeAsync(20_000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,51 +1,33 @@
|
|||||||
// TanStack Query bindings. Polling queries set `refetchIntervalInBackground`
|
// The app's cached server reads (see lib/query.ts), one per endpoint.
|
||||||
// because automation tabs (and a Chrome --app window behind another) report
|
|
||||||
// hidden, and a paused poll there shows frozen state.
|
|
||||||
|
|
||||||
import { queryOptions, QueryClient } from '@tanstack/react-query'
|
|
||||||
import { fetchBudget, fetchDataset } from '../api'
|
import { fetchBudget, fetchDataset } from '../api'
|
||||||
|
import { family, invalidateAll, Query } from '../lib/query'
|
||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { Job } from './jobContract'
|
import type { Job } from './jobContract'
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: { staleTime: 5_000, retry: 1, refetchOnWindowFocus: true },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const live = (ms: number) => ({ refetchInterval: ms, refetchIntervalInBackground: true })
|
|
||||||
|
|
||||||
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled'])
|
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled'])
|
||||||
export const isActive = (j: Job) => !TERMINAL.has(j.state)
|
export const isActive = (j: Job): boolean => !TERMINAL.has(j.state)
|
||||||
|
|
||||||
export const q = {
|
export const q = {
|
||||||
status: queryOptions({ queryKey: ['status'], queryFn: api.status, ...live(15_000) }),
|
status: new Query(api.status, { intervalMs: 15_000 }),
|
||||||
stats: queryOptions({ queryKey: ['stats'], queryFn: api.stats }),
|
stats: new Query(api.stats),
|
||||||
installed: queryOptions({ queryKey: ['installed'], queryFn: api.installed }),
|
installed: new Query(api.installed),
|
||||||
library: queryOptions({ queryKey: ['library'], queryFn: api.library, staleTime: 60_000 }),
|
library: new Query(api.library, { staleMs: 60_000 }),
|
||||||
commands: queryOptions({ queryKey: ['commands'], queryFn: api.commands, ...live(30_000) }),
|
commands: new Query(api.commands, { intervalMs: 30_000 }),
|
||||||
setup: queryOptions({ queryKey: ['setup'], queryFn: api.setup }),
|
setup: new Query(api.setup),
|
||||||
daemon: queryOptions({ queryKey: ['daemon'], queryFn: api.daemon, ...live(3_000) }),
|
daemon: new Query(api.daemon, { intervalMs: 3_000 }),
|
||||||
server: queryOptions({ queryKey: ['server'], queryFn: api.server, ...live(20_000) }),
|
server: new Query(api.server, { intervalMs: 20_000 }),
|
||||||
backups: queryOptions({ queryKey: ['backups'], queryFn: api.backups }),
|
backups: new Query(api.backups),
|
||||||
dataset: queryOptions({ queryKey: ['dataset'], queryFn: fetchDataset, staleTime: 60_000 }),
|
dataset: new Query(fetchDataset, { staleMs: 60_000 }),
|
||||||
budget: queryOptions({ queryKey: ['budget'], queryFn: () => fetchBudget(false), ...live(5_000) }),
|
budget: new Query(() => fetchBudget(false), { intervalMs: 5_000 }),
|
||||||
jobs: queryOptions({
|
demoBudget: new Query(() => fetchBudget(true), { intervalMs: 5_000 }),
|
||||||
queryKey: ['jobs'],
|
jobs: new Query(api.jobs, { intervalMs: (data) => (data?.some(isActive) ? 2_000 : 15_000) }),
|
||||||
queryFn: api.jobs,
|
job: family((id) =>
|
||||||
refetchInterval: (query) => (query.state.data?.some(isActive) ? 2_000 : 15_000),
|
new Query(() => api.job(id), { intervalMs: (data) => (data && !isActive(data) ? false : 2_000) }),
|
||||||
refetchIntervalInBackground: true,
|
),
|
||||||
}),
|
|
||||||
job: (id: string) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: ['jobs', id],
|
|
||||||
queryFn: () => api.job(id),
|
|
||||||
refetchInterval: (query) => (query.state.data && !isActive(query.state.data) ? false : 2_000),
|
|
||||||
refetchIntervalInBackground: true,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** After a job ends, anything it may have changed is stale. */
|
/** After a job ends, anything it may have changed is stale. */
|
||||||
export function refreshAfterJob(): void {
|
export function refreshAfterJob(): void {
|
||||||
void queryClient.invalidateQueries()
|
invalidateAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,94 +0,0 @@
|
|||||||
import { renderHook, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { makeSpec } from '../test/fixtures'
|
|
||||||
import { renderRouted, stubApi } from '../test/harness'
|
|
||||||
import { CommandButton, CommandCard } from './CommandCard'
|
|
||||||
import { RunnerContext, useRunner } from './runnerContext'
|
|
||||||
|
|
||||||
const run = vi.fn()
|
|
||||||
const withRunner = (ui: React.ReactNode) => (
|
|
||||||
<RunnerContext.Provider value={{ run, openPalette: () => {} }}>{ui}</RunnerContext.Provider>
|
|
||||||
)
|
|
||||||
|
|
||||||
describe('CommandCard', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
run.mockClear()
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the label, server description and a run button', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': [makeSpec('scan', { description: 'Scans achievements.' })] })
|
|
||||||
renderRouted(withRunner(<CommandCard name="scan" preset={{ app_id: 4 }} />))
|
|
||||||
expect(await screen.findByText('Scans achievements.')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('heading', { name: 'Scan' })).toBeInTheDocument()
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Run scan' }))
|
|
||||||
expect(run).toHaveBeenCalledWith('scan', { app_id: 4 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks privileged and countdown commands', async () => {
|
|
||||||
stubApi({
|
|
||||||
'GET /api/commands': [
|
|
||||||
makeSpec('gaming-reset', {
|
|
||||||
privileged: true,
|
|
||||||
friction: { phrase_template: 'x', countdown_seconds: 300 },
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
})
|
|
||||||
renderRouted(withRunner(<CommandCard name="gaming-reset" />))
|
|
||||||
expect(await screen.findByText('root')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('countdown')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows no countdown pill for a friction command without one', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': [makeSpec('unblock', { friction: { phrase_template: 'x' } })] })
|
|
||||||
renderRouted(withRunner(<CommandCard name="unblock" />))
|
|
||||||
await screen.findByText('Does unblock.')
|
|
||||||
expect(screen.queryByText('countdown')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stays focusable but inert while locked, and says why', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': [makeSpec('pick', { locked_reason: 'Locked by a manual pick' })] })
|
|
||||||
renderRouted(withRunner(<CommandCard name="pick" />))
|
|
||||||
const button = await screen.findByRole('button', { name: 'Run pick' })
|
|
||||||
expect(screen.getByText('Locked by a manual pick', { selector: '.cmd-desc' })).toBeInTheDocument()
|
|
||||||
expect(button).toHaveAttribute('aria-disabled', 'true')
|
|
||||||
expect(button).toHaveAttribute('title', 'Locked by a manual pick')
|
|
||||||
expect(document.querySelector('article')).toHaveClass('locked')
|
|
||||||
await userEvent.click(button)
|
|
||||||
expect(run).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is inert until the catalog has loaded', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': [] })
|
|
||||||
renderRouted(withRunner(<CommandCard name="scan" />))
|
|
||||||
const button = await screen.findByRole('button', { name: 'Open Scan' })
|
|
||||||
expect(screen.getByText('…')).toBeInTheDocument()
|
|
||||||
expect(button).toHaveAttribute('aria-disabled', 'true')
|
|
||||||
await userEvent.click(button)
|
|
||||||
expect(run).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('links to the owning screen for a non-job command', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': [makeSpec('status', { kind: 'view' })] })
|
|
||||||
const { router } = renderRouted(withRunner(<CommandButton name="status" tone="primary" />))
|
|
||||||
const link = await screen.findByRole('link', { name: 'Open Status' })
|
|
||||||
expect(link).toHaveClass('btn-primary')
|
|
||||||
await userEvent.click(link)
|
|
||||||
expect(router.state.location.pathname).toBe('/')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the given label and tone', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': [makeSpec('scan')] })
|
|
||||||
renderRouted(withRunner(<CommandButton name="scan" label="Scan now" tone="danger" />))
|
|
||||||
expect(await screen.findByRole('button', { name: 'Scan now' })).toHaveClass('btn-danger')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('runner context', () => {
|
|
||||||
it('defaults to no-ops without a provider', () => {
|
|
||||||
const { result } = renderHook(() => useRunner())
|
|
||||||
expect(result.current.run('scan')).toBeUndefined()
|
|
||||||
expect(result.current.openPalette()).toBeUndefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import type { CommandName } from '../api/contract'
|
|
||||||
import { q } from '../api/queries'
|
|
||||||
import { COMMAND_HOME, commandLabel, findCommand } from './catalog'
|
|
||||||
import { useRunner } from './runnerContext'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
name: CommandName
|
|
||||||
preset?: Record<string, number | string>
|
|
||||||
/** Override the button text (defaults to the command name). */
|
|
||||||
label?: string
|
|
||||||
tone?: 'primary' | 'ghost' | 'danger'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A command as a card: name, server description, lock reason. The button
|
|
||||||
* stays focusable while locked (aria-disabled), so a keyboard user can still
|
|
||||||
* reach it and read why.
|
|
||||||
*/
|
|
||||||
export function CommandCard({ name, preset, label, tone }: Props) {
|
|
||||||
const { data: commands } = useQuery(q.commands)
|
|
||||||
const spec = findCommand(commands, name)
|
|
||||||
const locked = spec?.locked_reason ?? null
|
|
||||||
const btn = tone ?? 'ghost'
|
|
||||||
return (
|
|
||||||
<article className={locked ? 'cmd-card locked' : 'cmd-card'}>
|
|
||||||
<h3 className="cmd-name">
|
|
||||||
{commandLabel(name)}
|
|
||||||
{spec?.privileged && <span className="pill pill-neutral">root</span>}
|
|
||||||
{spec?.friction?.countdown_seconds ? <span className="pill pill-warning">countdown</span> : null}
|
|
||||||
</h3>
|
|
||||||
<p className="cmd-desc">{locked ?? spec?.description ?? '…'}</p>
|
|
||||||
<CommandButton name={name} preset={preset} label={label} tone={btn} />
|
|
||||||
</article>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommandButton({ name, preset, label, tone = 'ghost' }: Props) {
|
|
||||||
const { run } = useRunner()
|
|
||||||
const { data: commands } = useQuery(q.commands)
|
|
||||||
const spec = findCommand(commands, name)
|
|
||||||
const locked = spec?.locked_reason ?? null
|
|
||||||
const text = label ?? (spec?.kind === 'job' ? `Run ${name}` : `Open ${commandLabel(name)}`)
|
|
||||||
if (spec && spec.kind !== 'job') {
|
|
||||||
return (
|
|
||||||
<Link to={COMMAND_HOME[name]} className={`btn btn-${tone}`}>
|
|
||||||
{text}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`btn btn-${tone}`}
|
|
||||||
aria-disabled={locked !== null || !spec}
|
|
||||||
title={locked ?? undefined}
|
|
||||||
onClick={() => {
|
|
||||||
if (spec && !locked) run(name, preset)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
import { act, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { apiError, renderRouted, stubApi } from '../test/harness'
|
|
||||||
import { makeSpec } from '../test/fixtures'
|
|
||||||
import { CommandPalette } from './CommandPalette'
|
|
||||||
|
|
||||||
const SPECS = [
|
|
||||||
makeSpec('scan', { description: 'Scan achievements.' }),
|
|
||||||
makeSpec('unblock', { description: 'Open the store.', friction: { phrase_template: 'x' } }),
|
|
||||||
makeSpec('status', { kind: 'view', description: 'Show status.' }),
|
|
||||||
makeSpec('pick', { description: 'Pick next.', locked_reason: 'Locked by a pick' }),
|
|
||||||
]
|
|
||||||
|
|
||||||
function open() {
|
|
||||||
const onClose = vi.fn()
|
|
||||||
const onRun = vi.fn()
|
|
||||||
const view = renderRouted(<CommandPalette onClose={onClose} onRun={onRun} />)
|
|
||||||
return { onClose, onRun, ...view }
|
|
||||||
}
|
|
||||||
const box = () => screen.getByRole('combobox', { name: 'Search commands' })
|
|
||||||
const opt = (title: string) =>
|
|
||||||
screen.getAllByRole('option').find((o) => o.querySelector('.palette-title')?.firstChild?.textContent === title)!
|
|
||||||
const active = () => screen.getAllByRole('option').findIndex((o) => o.getAttribute('aria-selected') === 'true')
|
|
||||||
|
|
||||||
describe('CommandPalette', () => {
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
|
||||||
|
|
||||||
it('lists every command, then the sections', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
open()
|
|
||||||
expect(await screen.findByText('Scan achievements.')).toBeInTheDocument()
|
|
||||||
expect(opt('Scan')).toHaveTextContent('run')
|
|
||||||
expect(opt('Unblock')).toHaveTextContent('run · confirm')
|
|
||||||
expect(opt('Status')).toHaveTextContent('open /')
|
|
||||||
expect(opt('Pick')).toHaveTextContent('locked')
|
|
||||||
expect(opt('Pick')).toHaveTextContent('Locked by a pick') // the reason replaces the description
|
|
||||||
expect(screen.getByText('Go to Dashboard')).toBeInTheDocument()
|
|
||||||
expect(box()).toHaveFocus()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows loading and then an error', async () => {
|
|
||||||
let fail: (r: unknown) => void = () => {}
|
|
||||||
stubApi({ 'GET /api/commands': () => new Promise((r) => (fail = r)) })
|
|
||||||
open()
|
|
||||||
expect(await screen.findByText(/Loading commands/)).toBeInTheDocument()
|
|
||||||
await act(async () => fail(apiError(500, 'op_failed', 'catalog broken')))
|
|
||||||
expect(await screen.findByText('catalog broken')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/Loading commands/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters by text and says when nothing matches', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
open()
|
|
||||||
await screen.findByText('Scan achievements.')
|
|
||||||
await userEvent.type(box(), 'store')
|
|
||||||
expect(screen.getAllByRole('option')).toHaveLength(2) // unblock + "Go to Store"
|
|
||||||
await userEvent.clear(box())
|
|
||||||
await userEvent.type(box(), 'zzz')
|
|
||||||
expect(screen.getByText('No command matches “zzz”.')).toBeInTheDocument()
|
|
||||||
expect(box()).not.toHaveAttribute('aria-activedescendant')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('moves with arrows and pages, clamped, and announces the active option', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
open()
|
|
||||||
await screen.findByText('Scan achievements.')
|
|
||||||
await userEvent.keyboard('{ArrowDown}{ArrowDown}')
|
|
||||||
expect(active()).toBe(2)
|
|
||||||
expect(box().getAttribute('aria-activedescendant')).toBe(screen.getAllByRole('option')[2].id)
|
|
||||||
await userEvent.keyboard('{ArrowUp}{ArrowUp}{ArrowUp}')
|
|
||||||
expect(active()).toBe(0)
|
|
||||||
await userEvent.keyboard('{PageDown}')
|
|
||||||
expect(active()).toBe(8)
|
|
||||||
await userEvent.keyboard('{PageDown}')
|
|
||||||
expect(active()).toBe(11) // 4 commands + 8 sections - 1
|
|
||||||
await userEvent.keyboard('{PageUp}')
|
|
||||||
expect(active()).toBe(3)
|
|
||||||
await userEvent.keyboard('{PageUp}')
|
|
||||||
expect(active()).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('the pointer moves the highlight too', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
open()
|
|
||||||
await screen.findByText('Scan achievements.')
|
|
||||||
await userEvent.hover(screen.getAllByRole('option')[1])
|
|
||||||
expect(active()).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Enter runs a job command', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
const { onRun } = open()
|
|
||||||
await screen.findByText('Scan achievements.')
|
|
||||||
await userEvent.keyboard('{Enter}')
|
|
||||||
expect(onRun).toHaveBeenCalledWith('scan')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Enter on a view command navigates and closes', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
const { onClose, onRun, router } = open()
|
|
||||||
await screen.findByText('Scan achievements.')
|
|
||||||
await userEvent.type(box(), 'show status')
|
|
||||||
await userEvent.keyboard('{Enter}')
|
|
||||||
expect(onRun).not.toHaveBeenCalled()
|
|
||||||
expect(onClose).toHaveBeenCalled()
|
|
||||||
expect(router.state.location.pathname).toBe('/')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clicking a section navigates', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
const { onClose, router } = open()
|
|
||||||
await userEvent.click(await screen.findByText('Go to Jobs'))
|
|
||||||
expect(onClose).toHaveBeenCalled()
|
|
||||||
expect(router.state.location.pathname).toBe('/jobs')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Enter with nothing shown does nothing', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
const { onRun, onClose } = open()
|
|
||||||
await screen.findByText('Scan achievements.')
|
|
||||||
await userEvent.type(box(), 'zzz{Enter}')
|
|
||||||
expect(onRun).not.toHaveBeenCalled()
|
|
||||||
expect(onClose).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores other keys', async () => {
|
|
||||||
stubApi({ 'GET /api/commands': SPECS })
|
|
||||||
const { onRun } = open()
|
|
||||||
await screen.findByText('Scan achievements.')
|
|
||||||
await userEvent.keyboard('{Tab}{Shift}')
|
|
||||||
expect(onRun).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { useNavigate } from '@tanstack/react-router'
|
|
||||||
import { useId, useState, type KeyboardEvent } from 'react'
|
|
||||||
import type { CommandName } from '../api/contract'
|
|
||||||
import { q } from '../api/queries'
|
|
||||||
import { NAV } from '../shell/nav'
|
|
||||||
import { Dialog } from '../ui/Dialog'
|
|
||||||
import { ErrorNotice, Loading } from '../ui/Notice'
|
|
||||||
import { COMMAND_HOME, commandLabel, type SectionPath } from './catalog'
|
|
||||||
|
|
||||||
interface Entry {
|
|
||||||
key: string
|
|
||||||
title: string
|
|
||||||
detail: string
|
|
||||||
hint: string
|
|
||||||
locked: string | null
|
|
||||||
act: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ctrl/Cmd+K: every command from `GET /api/commands`, plus the sections.
|
|
||||||
* A combobox + listbox pair: arrows move, Enter runs, Escape closes, and the
|
|
||||||
* active option is announced via aria-activedescendant.
|
|
||||||
*/
|
|
||||||
export function CommandPalette({ onClose, onRun }: { onClose: () => void; onRun: (n: CommandName) => void }) {
|
|
||||||
const id = useId()
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { data: commands, error } = useQuery(q.commands)
|
|
||||||
const [text, setText] = useState('')
|
|
||||||
const [active, setActive] = useState(0)
|
|
||||||
|
|
||||||
const go = (to: SectionPath | '/jobs') => {
|
|
||||||
onClose()
|
|
||||||
void navigate({ to })
|
|
||||||
}
|
|
||||||
const entries: Entry[] = [
|
|
||||||
...(commands ?? []).map((c) => ({
|
|
||||||
key: c.name,
|
|
||||||
title: commandLabel(c.name),
|
|
||||||
detail: c.description,
|
|
||||||
hint: c.kind === 'job' ? (c.friction ? 'run · confirm' : 'run') : `open ${COMMAND_HOME[c.name]}`,
|
|
||||||
locked: c.locked_reason,
|
|
||||||
act: () => (c.kind === 'job' ? onRun(c.name) : go(COMMAND_HOME[c.name])),
|
|
||||||
})),
|
|
||||||
...NAV.map((n) => ({ key: `nav:${n.to}`, title: `Go to ${n.label}`, detail: n.blurb, hint: 'navigate', locked: null, act: () => go(n.to) })),
|
|
||||||
]
|
|
||||||
const needle = text.trim().toLowerCase()
|
|
||||||
const shown = needle
|
|
||||||
? entries.filter((e) => `${e.key} ${e.title} ${e.detail}`.toLowerCase().includes(needle))
|
|
||||||
: entries
|
|
||||||
const current = Math.min(active, Math.max(0, shown.length - 1))
|
|
||||||
|
|
||||||
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
const last = shown.length - 1
|
|
||||||
const moves: Record<string, number> = {
|
|
||||||
ArrowDown: Math.min(last, current + 1),
|
|
||||||
ArrowUp: Math.max(0, current - 1),
|
|
||||||
PageDown: Math.min(last, current + 8),
|
|
||||||
PageUp: Math.max(0, current - 8),
|
|
||||||
}
|
|
||||||
if (e.key in moves) {
|
|
||||||
e.preventDefault()
|
|
||||||
setActive(moves[e.key])
|
|
||||||
document.getElementById(`${id}-${moves[e.key]}`)?.scrollIntoView({ block: 'nearest' })
|
|
||||||
} else if (e.key === 'Enter' && shown[current]) {
|
|
||||||
e.preventDefault()
|
|
||||||
shown[current].act()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open title="Command palette" onClose={onClose}>
|
|
||||||
<input
|
|
||||||
className="input palette-input"
|
|
||||||
role="combobox"
|
|
||||||
aria-expanded="true"
|
|
||||||
aria-controls={`${id}-list`}
|
|
||||||
aria-activedescendant={shown[current] ? `${id}-${current}` : undefined}
|
|
||||||
aria-label="Search commands"
|
|
||||||
placeholder="Type a command or section…"
|
|
||||||
value={text}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => {
|
|
||||||
setText(e.target.value)
|
|
||||||
setActive(0)
|
|
||||||
}}
|
|
||||||
onKeyDown={onKey}
|
|
||||||
/>
|
|
||||||
{error && <ErrorNotice error={error} />}
|
|
||||||
{!commands && !error && <Loading what="commands" />}
|
|
||||||
<ul id={`${id}-list`} role="listbox" className="palette-list" aria-label="Commands">
|
|
||||||
{shown.map((e, i) => (
|
|
||||||
<li
|
|
||||||
key={e.key}
|
|
||||||
id={`${id}-${i}`}
|
|
||||||
role="option"
|
|
||||||
aria-selected={i === current}
|
|
||||||
className={i === current ? 'palette-item active' : 'palette-item'}
|
|
||||||
onMouseMove={() => setActive(i)}
|
|
||||||
onClick={e.act}
|
|
||||||
>
|
|
||||||
<span className="palette-title">
|
|
||||||
{e.title}
|
|
||||||
{e.locked && <span className="pill pill-warning">locked</span>}
|
|
||||||
</span>
|
|
||||||
<span className="palette-detail">{e.locked ?? e.detail}</span>
|
|
||||||
<span className="palette-hint">{e.hint}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{shown.length === 0 && <li className="empty">No command matches “{text}”.</li>}
|
|
||||||
</ul>
|
|
||||||
<p className="field-help">↑↓ to move · Enter to run · Esc to close</p>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import type { ParamSpec } from '../api/contract'
|
|
||||||
import { ParamsForm } from './ParamsForm'
|
|
||||||
import type { ParamValues } from './params'
|
|
||||||
|
|
||||||
const SPECS: ParamSpec[] = [
|
|
||||||
{ name: 'app_id', label: 'Game', type: 'app_id', required: true },
|
|
||||||
{ name: 'days', label: 'Days', type: 'int', required: false, min: 1, max: 9, help: 'How long.' },
|
|
||||||
{ name: 'note', label: 'Note', type: 'string', required: false },
|
|
||||||
]
|
|
||||||
const GAMES = [{ app_id: 5, name: 'Five' }]
|
|
||||||
|
|
||||||
function Harness({ showErrors = false, disabled = false }) {
|
|
||||||
const [values, setValues] = useState<ParamValues>({ app_id: '', days: '', note: '' })
|
|
||||||
return <ParamsForm specs={SPECS} values={values} onChange={setValues} games={GAMES} showErrors={showErrors} disabled={disabled} />
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('ParamsForm', () => {
|
|
||||||
it('renders one labelled field per spec, marking the required ones', () => {
|
|
||||||
render(<Harness />)
|
|
||||||
expect(screen.getByLabelText(/Game/)).toHaveAttribute('list')
|
|
||||||
expect(screen.getByLabelText('Days')).toHaveAttribute('type', 'number')
|
|
||||||
expect(screen.getByLabelText('Days')).toHaveAttribute('min', '1')
|
|
||||||
expect(screen.getByLabelText('Note')).toHaveAttribute('type', 'text')
|
|
||||||
expect(screen.getByLabelText('Note')).not.toHaveAttribute('inputmode')
|
|
||||||
expect(screen.getByLabelText(/Game/)).toHaveFocus()
|
|
||||||
expect(screen.getByText('Type an app id or pick a game from the list.')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('How long.')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('offers the known games in a datalist and names the chosen one', async () => {
|
|
||||||
const { container } = render(<Harness />)
|
|
||||||
expect(container.querySelector('datalist option')).toHaveValue('5')
|
|
||||||
await userEvent.type(screen.getByLabelText(/Game/), '5')
|
|
||||||
expect(container.querySelector('.field-help')).toHaveTextContent('Five')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows errors only once asked to', async () => {
|
|
||||||
const { rerender } = render(<Harness />)
|
|
||||||
expect(screen.queryByText('Game is required.')).toBeNull()
|
|
||||||
rerender(<Harness showErrors />)
|
|
||||||
expect(screen.getByText('Game is required.')).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText(/Game/)).toHaveClass('input-bad')
|
|
||||||
expect(screen.getByLabelText(/Game/)).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('treats a value the form has not set yet as empty', () => {
|
|
||||||
render(<ParamsForm specs={SPECS} values={{}} onChange={() => {}} games={GAMES} showErrors={false} disabled={false} />)
|
|
||||||
expect(screen.getByLabelText('Note')).toHaveValue('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('disables every field', () => {
|
|
||||||
render(<Harness disabled />)
|
|
||||||
expect(screen.getByLabelText('Note')).toBeDisabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders a bool as an unchecked checkbox that toggles 0/1', async () => {
|
|
||||||
const box: ParamSpec[] = [{ name: 'replace', label: 'Replace', type: 'bool', required: false, default: 0 }]
|
|
||||||
function BoxHarness() {
|
|
||||||
const [values, setValues] = useState<ParamValues>({ replace: '0' })
|
|
||||||
return <ParamsForm specs={box} values={values} onChange={setValues} games={[]} showErrors disabled={false} />
|
|
||||||
}
|
|
||||||
render(<BoxHarness />)
|
|
||||||
const input = screen.getByLabelText('Replace')
|
|
||||||
expect(input).toHaveAttribute('type', 'checkbox')
|
|
||||||
expect(input).not.toBeChecked()
|
|
||||||
await userEvent.click(input)
|
|
||||||
expect(input).toBeChecked()
|
|
||||||
await userEvent.click(input)
|
|
||||||
expect(input).not.toBeChecked()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
import { useId } from 'react'
|
|
||||||
import type { ParamSpec } from '../api/contract'
|
|
||||||
import { paramError, type GameOption, type ParamValues } from './params'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
specs: ParamSpec[]
|
|
||||||
values: ParamValues
|
|
||||||
onChange: (v: ParamValues) => void
|
|
||||||
games: GameOption[]
|
|
||||||
showErrors: boolean
|
|
||||||
disabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ParamsForm({ specs, values, onChange, games, showErrors, disabled }: Props) {
|
|
||||||
const base = useId()
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{specs.map((p, i) => {
|
|
||||||
const id = `${base}-${p.name}`
|
|
||||||
const raw = values[p.name] ?? ''
|
|
||||||
if (p.type === 'bool') {
|
|
||||||
return (
|
|
||||||
<div className="form-field" key={p.name}>
|
|
||||||
<label className="check" htmlFor={id}>
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
type="checkbox"
|
|
||||||
checked={raw === '1'}
|
|
||||||
disabled={disabled}
|
|
||||||
aria-describedby={`${id}-help`}
|
|
||||||
onChange={(e) => onChange({ ...values, [p.name]: e.target.checked ? '1' : '0' })}
|
|
||||||
/>
|
|
||||||
{p.label}
|
|
||||||
</label>
|
|
||||||
<p id={`${id}-help`} className="field-help">
|
|
||||||
{p.help ?? ' '}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const err = paramError(p, raw, games)
|
|
||||||
const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined
|
|
||||||
return (
|
|
||||||
<div className="form-field" key={p.name}>
|
|
||||||
<label htmlFor={id} className="field-label">
|
|
||||||
{p.label}
|
|
||||||
{p.required && <span aria-hidden="true"> *</span>}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
className={`input ${p.type === 'string' ? '' : 'input-num'}${showErrors && err ? ' input-bad' : ''}`}
|
|
||||||
type={p.type === 'int' ? 'number' : 'text'}
|
|
||||||
inputMode={p.type === 'string' ? undefined : 'numeric'}
|
|
||||||
min={p.min}
|
|
||||||
max={p.max}
|
|
||||||
list={p.type === 'app_id' ? `${id}-games` : undefined}
|
|
||||||
value={raw}
|
|
||||||
required={p.required}
|
|
||||||
disabled={disabled}
|
|
||||||
autoFocus={i === 0}
|
|
||||||
aria-invalid={showErrors && err !== null}
|
|
||||||
aria-describedby={`${id}-help`}
|
|
||||||
onChange={(e) => onChange({ ...values, [p.name]: e.target.value })}
|
|
||||||
/>
|
|
||||||
{p.type === 'app_id' && (
|
|
||||||
<datalist id={`${id}-games`}>
|
|
||||||
{games.map((g) => (
|
|
||||||
<option key={g.app_id} value={g.app_id}>
|
|
||||||
{g.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
)}
|
|
||||||
<p id={`${id}-help`} className={showErrors && err ? 'field-help text-danger' : 'field-help'}>
|
|
||||||
{showErrors && err ? err : game ? game.name : (p.help ?? (p.type === 'app_id' ? 'Type an app id or pick a game from the list.' : ' '))}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { queryClient } from '../api/queries'
|
|
||||||
import { makeJob, makePending } from '../test/fixtures'
|
|
||||||
import { apiError, stubApi } from '../test/harness'
|
|
||||||
import { PendingCountdown } from './PendingCountdown'
|
|
||||||
|
|
||||||
const T0 = Date.parse('2026-10-10T10:00:00Z')
|
|
||||||
const pending = (over = {}) =>
|
|
||||||
makePending({ armed_at: '2026-10-10T10:00:00Z', ready_at: '2026-10-10T10:00:30Z', heartbeat_interval_seconds: 10, ...over })
|
|
||||||
|
|
||||||
function mount(over = {}) {
|
|
||||||
const cbs = { onCommitted: vi.fn(), onCancel: vi.fn(), onRearm: vi.fn() }
|
|
||||||
const p = pending(over)
|
|
||||||
const view = render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<PendingCountdown pending={p} {...cbs} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
)
|
|
||||||
return { ...cbs, ...view }
|
|
||||||
}
|
|
||||||
const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms) })
|
|
||||||
const user = () => userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
|
||||||
|
|
||||||
describe('PendingCountdown', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers({ now: T0 })
|
|
||||||
// Testing Library only advances fake timers for a `jest` global; without
|
|
||||||
// this shim its async helpers wait on a timer that never fires.
|
|
||||||
vi.stubGlobal('jest', { advanceTimersByTime: vi.advanceTimersByTime })
|
|
||||||
})
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('counts down on the server’s clock', async () => {
|
|
||||||
stubApi({ 'POST /api/pending/p1/heartbeat': pending() })
|
|
||||||
mount()
|
|
||||||
// One second past ready_at: a slightly fast browser clock must not offer
|
|
||||||
// a commit the daemon refuses.
|
|
||||||
expect(screen.getByText('0:31')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('until commit unlocks')).toBeInTheDocument()
|
|
||||||
await tick(10_000)
|
|
||||||
expect(screen.getByText('0:21')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('progressbar', { name: 'Countdown' })).toHaveAttribute('aria-valuenow', '32')
|
|
||||||
expect(screen.getByText(/Closing this dialog/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: 'Cancel countdown' })).toHaveFocus()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps the commit locked until a second after the server’s ready time', async () => {
|
|
||||||
stubApi({ 'POST /api/pending/p1/heartbeat': pending() })
|
|
||||||
mount()
|
|
||||||
await tick(30_000)
|
|
||||||
expect(screen.getByText('until commit unlocks')).toBeInTheDocument()
|
|
||||||
await tick(1_000)
|
|
||||||
expect(screen.getByText('Ready to commit')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('heartbeats at half the interval, and takes the server’s new ready time', async () => {
|
|
||||||
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': () => pending({ ready_at: '2026-10-10T10:01:00Z' }) })
|
|
||||||
mount()
|
|
||||||
await tick(4_999)
|
|
||||||
expect(mock).not.toHaveBeenCalled()
|
|
||||||
await tick(1)
|
|
||||||
expect(mock).toHaveBeenCalledTimes(1)
|
|
||||||
expect(screen.getByText(/Heartbeat every 5 s · last 0 s ago/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('0:56')).toBeInTheDocument() // pushed out by the server
|
|
||||||
})
|
|
||||||
|
|
||||||
it('never heartbeats faster than once a second', async () => {
|
|
||||||
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending({ heartbeat_interval_seconds: 0.5 }) })
|
|
||||||
mount({ heartbeat_interval_seconds: 0.5 })
|
|
||||||
await tick(999)
|
|
||||||
expect(mock).not.toHaveBeenCalled()
|
|
||||||
await tick(1)
|
|
||||||
expect(mock).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports a failing heartbeat and recovers when it works again', async () => {
|
|
||||||
const routes: Record<string, unknown> = { 'POST /api/pending/p1/heartbeat': apiError(503, 'daemon_unreachable') }
|
|
||||||
stubApi(routes)
|
|
||||||
mount()
|
|
||||||
await tick(5_000)
|
|
||||||
expect(screen.getByText(/heartbeat failing, retrying/)).toBeInTheDocument()
|
|
||||||
routes['POST /api/pending/p1/heartbeat'] = pending()
|
|
||||||
await tick(5_000)
|
|
||||||
expect(screen.queryByText(/heartbeat failing/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('offers to arm again once the server says the countdown lapsed', async () => {
|
|
||||||
stubApi({ 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed a beat') })
|
|
||||||
const { onRearm } = mount()
|
|
||||||
await tick(5_000)
|
|
||||||
expect(screen.getByText('missed a beat')).toBeInTheDocument()
|
|
||||||
await user().click(screen.getByRole('button', { name: 'Arm again' }))
|
|
||||||
expect(onRearm).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('drops the action with a keepalive request when the page is hidden', async () => {
|
|
||||||
const mock = stubApi({ 'DELETE /api/pending/p1': undefined })
|
|
||||||
mount()
|
|
||||||
window.dispatchEvent(new Event('pagehide'))
|
|
||||||
expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE', keepalive: true }))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('swallows a failed keepalive drop', async () => {
|
|
||||||
stubApi({ 'DELETE /api/pending/p1': apiError(500, 'op_failed') })
|
|
||||||
mount()
|
|
||||||
window.dispatchEvent(new Event('pagehide'))
|
|
||||||
await tick(0)
|
|
||||||
expect(screen.getByText('0:31')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stops heartbeating and listening on unmount', async () => {
|
|
||||||
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined })
|
|
||||||
const { unmount } = mount()
|
|
||||||
unmount()
|
|
||||||
await tick(60_000)
|
|
||||||
window.dispatchEvent(new Event('pagehide'))
|
|
||||||
expect(mock).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cancels, whatever the server answers', async () => {
|
|
||||||
stubApi({ 'DELETE /api/pending/p1': apiError(404, 'not_found') })
|
|
||||||
const { onCancel } = mount()
|
|
||||||
await user().click(screen.getByRole('button', { name: 'Cancel countdown' }))
|
|
||||||
await tick(0)
|
|
||||||
expect(onCancel).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Cancelling… while the request runs', async () => {
|
|
||||||
stubApi({ 'DELETE /api/pending/p1': () => new Promise(() => {}) })
|
|
||||||
mount()
|
|
||||||
await user().click(screen.getByRole('button', { name: 'Cancel countdown' }))
|
|
||||||
expect(screen.getByRole('button', { name: 'Cancelling…' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('once ready', () => {
|
|
||||||
const ready = async (over = {}) => {
|
|
||||||
const m = mount(over)
|
|
||||||
await tick(31_000)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
it('asks for the phrase again, and commits only when it matches', async () => {
|
|
||||||
const job = makeJob({ id: 'jc' })
|
|
||||||
stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': job })
|
|
||||||
const { onCommitted } = await ready()
|
|
||||||
expect(screen.getByText('Ready to commit')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
|
||||||
const commit = screen.getByRole('button', { name: 'Commit' })
|
|
||||||
expect(commit).toBeDisabled()
|
|
||||||
fireEvent.submit(commit.closest('form')!) // Enter with the wrong phrase
|
|
||||||
expect(onCommitted).not.toHaveBeenCalled()
|
|
||||||
await user().type(screen.getByRole('textbox'), 'reset gaming now')
|
|
||||||
expect(commit).toBeEnabled()
|
|
||||||
await user().click(commit)
|
|
||||||
await tick(0)
|
|
||||||
expect(onCommitted.mock.calls[0][0]).toEqual(job)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a refused commit', async () => {
|
|
||||||
stubApi({
|
|
||||||
'POST /api/pending/p1/heartbeat': pending(),
|
|
||||||
'POST /api/pending/p1/commit': apiError(409, 'countdown_running', 'not yet'),
|
|
||||||
})
|
|
||||||
await ready()
|
|
||||||
await user().type(screen.getByRole('textbox'), 'reset gaming now')
|
|
||||||
await user().click(screen.getByRole('button', { name: 'Commit' }))
|
|
||||||
await tick(0)
|
|
||||||
expect(screen.getByText('not yet')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Committing… while it is in flight', async () => {
|
|
||||||
stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': () => new Promise(() => {}) })
|
|
||||||
await ready()
|
|
||||||
await user().type(screen.getByRole('textbox'), 'reset gaming now')
|
|
||||||
await user().click(screen.getByRole('button', { name: 'Commit' }))
|
|
||||||
expect(screen.getByRole('button', { name: 'Committing…' })).toBeDisabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can still be cancelled', async () => {
|
|
||||||
stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined })
|
|
||||||
const { onCancel } = await ready()
|
|
||||||
await user().click(screen.getByRole('button', { name: 'Cancel' }))
|
|
||||||
await tick(0)
|
|
||||||
expect(onCancel).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { api, ApiFailure } from '../api/client'
|
|
||||||
import type { Job, PendingAction } from '../api/jobContract'
|
|
||||||
import { ProgressBar } from '../jobs/ProgressBar'
|
|
||||||
import { clock } from '../ui/format'
|
|
||||||
import { ErrorNotice, Notice } from '../ui/Notice'
|
|
||||||
import { useNow } from '../ui/useNow'
|
|
||||||
import { phraseMatches } from './catalog'
|
|
||||||
import { PhraseField } from './PhraseField'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The daemon checks `ready_at` on its own clock; unlocking the commit a
|
|
||||||
* second late keeps a slightly fast browser clock from offering a commit the
|
|
||||||
* daemon refuses (seen in vmbox: refused 34 ms early, countdown_running).
|
|
||||||
*/
|
|
||||||
const CLOCK_SLACK_MS = 1000
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
pending: PendingAction
|
|
||||||
onCommitted: (job: Job) => void
|
|
||||||
onCancel: () => void
|
|
||||||
onRearm: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Armed countdown (gaming-reset): heartbeats at half the server's interval so
|
|
||||||
* one slow request never lapses it, a countdown driven by the server's
|
|
||||||
* `ready_at` (not a local timer), then the phrase is retyped to commit.
|
|
||||||
* Closing the page drops the action with a keepalive DELETE.
|
|
||||||
*/
|
|
||||||
export function PendingCountdown({ pending: initial, onCommitted, onCancel, onRearm }: Props) {
|
|
||||||
const [pending, setPending] = useState(initial)
|
|
||||||
const [lastBeat, setLastBeat] = useState(() => Date.now())
|
|
||||||
const [beatError, setBeatError] = useState<unknown>(null)
|
|
||||||
const [phrase, setPhrase] = useState('')
|
|
||||||
const now = useNow(250)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const everyMs = Math.max(1000, (initial.heartbeat_interval_seconds * 1000) / 2)
|
|
||||||
const id = setInterval(() => {
|
|
||||||
api.heartbeat(initial.id).then(
|
|
||||||
(p) => {
|
|
||||||
setPending(p)
|
|
||||||
setLastBeat(Date.now())
|
|
||||||
setBeatError(null)
|
|
||||||
},
|
|
||||||
(e: unknown) => setBeatError(e),
|
|
||||||
)
|
|
||||||
}, everyMs)
|
|
||||||
const drop = () => void api.dropPending(initial.id, true).catch(() => {})
|
|
||||||
window.addEventListener('pagehide', drop)
|
|
||||||
return () => {
|
|
||||||
clearInterval(id)
|
|
||||||
window.removeEventListener('pagehide', drop)
|
|
||||||
}
|
|
||||||
}, [initial.id, initial.heartbeat_interval_seconds])
|
|
||||||
|
|
||||||
const commit = useMutation({ mutationFn: () => api.commit(pending.id, phrase), onSuccess: onCommitted })
|
|
||||||
const cancel = useMutation({ mutationFn: () => api.dropPending(pending.id), onSettled: onCancel })
|
|
||||||
|
|
||||||
const armed = Date.parse(pending.armed_at)
|
|
||||||
const ready = Date.parse(pending.ready_at) + CLOCK_SLACK_MS
|
|
||||||
const left = Math.max(0, (ready - now) / 1000)
|
|
||||||
const isReady = left <= 0
|
|
||||||
const lapsed = beatError instanceof ApiFailure && beatError.code === 'pending_lapsed'
|
|
||||||
const sinceBeat = Math.round((now - lastBeat) / 1000)
|
|
||||||
|
|
||||||
if (lapsed) {
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
<ErrorNotice error={beatError} />
|
|
||||||
<div className="actions">
|
|
||||||
<button type="button" className="btn btn-primary" onClick={onRearm} autoFocus>
|
|
||||||
Arm again
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack countdown">
|
|
||||||
<div className="countdown-clock" aria-live="off">
|
|
||||||
<span className="countdown-num">{clock(left)}</span>
|
|
||||||
<span className="muted">{isReady ? 'Ready to commit' : 'until commit unlocks'}</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar fraction={Math.min(1, (now - armed) / (ready - armed))} label="Countdown" tone={isReady ? 'success' : 'warning'} />
|
|
||||||
<p className="field-help">
|
|
||||||
Keep this window open. Heartbeat every {Math.round(pending.heartbeat_interval_seconds / 2)} s · last {sinceBeat} s ago
|
|
||||||
{beatError !== null && <span className="text-danger"> · heartbeat failing, retrying</span>}
|
|
||||||
</p>
|
|
||||||
<p className="sr-only" aria-live="polite">
|
|
||||||
{isReady ? 'Countdown finished. Retype the phrase to commit.' : ''}
|
|
||||||
</p>
|
|
||||||
{isReady ? (
|
|
||||||
<form
|
|
||||||
className="stack"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (phraseMatches(pending.phrase, phrase)) commit.mutate()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PhraseField phrase={pending.phrase} value={phrase} onChange={setPhrase} autoFocus label="Retype the phrase to commit" />
|
|
||||||
{commit.error && <ErrorNotice error={commit.error} />}
|
|
||||||
<div className="actions">
|
|
||||||
<button type="submit" className="btn btn-danger" disabled={!phraseMatches(pending.phrase, phrase) || commit.isPending}>
|
|
||||||
{commit.isPending ? 'Committing…' : 'Commit'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => cancel.mutate()}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Notice tone="warning">Closing this dialog, navigating away or closing the tab cancels the countdown.</Notice>
|
|
||||||
<div className="actions">
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={() => cancel.mutate()} autoFocus>
|
|
||||||
{cancel.isPending ? 'Cancelling…' : 'Cancel countdown'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { PhraseField } from './PhraseField'
|
|
||||||
|
|
||||||
function Harness({ initial = '', autoFocus = false }: { initial?: string; autoFocus?: boolean }) {
|
|
||||||
const [v, setV] = useState(initial)
|
|
||||||
return <PhraseField phrase="reset it" value={v} onChange={setV} autoFocus={autoFocus} />
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PhraseField', () => {
|
|
||||||
it('counts characters while the prefix is right', async () => {
|
|
||||||
render(<Harness />)
|
|
||||||
await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), 'res')
|
|
||||||
expect(screen.getByText('3 of 8 characters')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'false')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks a mismatch', async () => {
|
|
||||||
render(<Harness />)
|
|
||||||
await userEvent.type(screen.getByRole('textbox'), 'rx')
|
|
||||||
expect(screen.getByText(/Does not match/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('textbox')).toHaveClass('input-bad')
|
|
||||||
expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('confirms the full phrase, ignoring surrounding whitespace', () => {
|
|
||||||
render(<Harness initial=" reset it " />)
|
|
||||||
expect(screen.getByText('Matches.')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('textbox')).toHaveClass('input-ok')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('colours each target character by whether it was typed right', () => {
|
|
||||||
const { container } = render(<Harness initial="rx" />)
|
|
||||||
const spans = [...container.querySelectorAll('.phrase-target span')]
|
|
||||||
expect(spans[0]).toHaveClass('ok')
|
|
||||||
expect(spans[1]).toHaveClass('bad')
|
|
||||||
expect(spans[2]).not.toHaveClass('ok')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks paste and drop', () => {
|
|
||||||
render(<Harness />)
|
|
||||||
const input = screen.getByRole('textbox')
|
|
||||||
expect(fireEvent.paste(input)).toBe(false)
|
|
||||||
expect(fireEvent.drop(input)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('focuses itself on request and shows the phrase to screen readers', () => {
|
|
||||||
render(<Harness autoFocus />)
|
|
||||||
expect(screen.getByRole('textbox')).toHaveFocus()
|
|
||||||
expect(screen.getByText('Phrase: reset it')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,58 +0,0 @@
|
|||||||
import { useId } from 'react'
|
|
||||||
import { phraseMatches } from './catalog'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
phrase: string
|
|
||||||
value: string
|
|
||||||
onChange: (v: string) => void
|
|
||||||
autoFocus?: boolean
|
|
||||||
label?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Typed-phrase friction. Shows the exact sentence, then live per-character
|
|
||||||
* feedback on the typed text. Paste is blocked: retyping is the friction.
|
|
||||||
* The server re-checks — this field only helps the user get it right.
|
|
||||||
*/
|
|
||||||
export function PhraseField({ phrase, value, onChange, autoFocus = false, label = 'Type the phrase to confirm' }: Props) {
|
|
||||||
const id = useId()
|
|
||||||
const typed = value.trim()
|
|
||||||
const ok = phraseMatches(phrase, value)
|
|
||||||
const prefixOk = phrase.startsWith(typed)
|
|
||||||
const status = ok ? 'Matches.' : prefixOk ? `${typed.length} of ${phrase.length} characters` : 'Does not match — check case and spacing.'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="phrase">
|
|
||||||
<label htmlFor={id} className="field-label">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
<p className="phrase-target" aria-hidden="true">
|
|
||||||
{[...phrase].map((ch, i) => {
|
|
||||||
const cls = i < typed.length ? (typed[i] === ch ? 'ok' : 'bad') : ''
|
|
||||||
return (
|
|
||||||
<span key={i} className={cls}>
|
|
||||||
{ch}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
id={id}
|
|
||||||
className={`input mono ${ok ? 'input-ok' : prefixOk ? '' : 'input-bad'}`}
|
|
||||||
value={value}
|
|
||||||
autoFocus={autoFocus}
|
|
||||||
autoComplete="off"
|
|
||||||
spellCheck={false}
|
|
||||||
aria-describedby={`${id}-status`}
|
|
||||||
aria-invalid={!prefixOk}
|
|
||||||
onPaste={(e) => e.preventDefault()}
|
|
||||||
onDrop={(e) => e.preventDefault()}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
/>
|
|
||||||
<p className="sr-only">Phrase: {phrase}</p>
|
|
||||||
<p id={`${id}-status`} className={`field-help ${ok ? 'text-success' : prefixOk ? '' : 'text-danger'}`} aria-live="polite">
|
|
||||||
{status}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { routes } from '../test/dialogRoutes'
|
|
||||||
import { makeJob, makePending, makeSpec } from '../test/fixtures'
|
|
||||||
import { apiError, bodiesOf, installEventSource, renderRouted, stubApi } from '../test/harness'
|
|
||||||
import { RunnerDialog } from './RunnerDialog'
|
|
||||||
|
|
||||||
const SPEC = makeSpec('gaming-reset', {
|
|
||||||
privileged: true,
|
|
||||||
friction: { phrase_template: 'reset gaming now', countdown_seconds: 300 },
|
|
||||||
})
|
|
||||||
const READY = makePending({ ready_at: '2026-10-10T09:00:00Z', armed_at: '2026-10-10T08:55:00Z' })
|
|
||||||
const FAR = makePending({ ready_at: '2099-01-01T00:00:00Z', armed_at: '2026-10-10T08:55:00Z' })
|
|
||||||
|
|
||||||
function open(extra: Record<string, unknown>) {
|
|
||||||
const mock = stubApi(routes([SPEC], extra))
|
|
||||||
const onClose = vi.fn()
|
|
||||||
const view = renderRouted(<RunnerDialog name="gaming-reset" onClose={onClose} />)
|
|
||||||
return { mock, onClose, ...view }
|
|
||||||
}
|
|
||||||
const arm = async () => {
|
|
||||||
await userEvent.type(await screen.findByLabelText('Type the phrase to confirm'), 'reset gaming now')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Arm countdown' }))
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RunnerDialog countdown commands', () => {
|
|
||||||
beforeEach(installEventSource)
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
|
||||||
|
|
||||||
it('sends the phrase to arm, then commits with a retyped phrase and follows the job', async () => {
|
|
||||||
const { mock } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1', command: 'gaming-reset' }) })
|
|
||||||
await arm()
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([
|
|
||||||
{ command: 'gaming-reset', params: {}, confirm_phrase: 'reset gaming now' },
|
|
||||||
])
|
|
||||||
await userEvent.type(await screen.findByLabelText('Retype the phrase to commit'), 'reset gaming now')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Commit' }))
|
|
||||||
expect(await screen.findByRole('region', { name: /^Job/ })).toBeInTheDocument()
|
|
||||||
expect(bodiesOf(mock, 'POST /api/pending/p1/commit')).toEqual([{ confirm_phrase: 'reset gaming now' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cancelling the countdown closes the dialog and the server drops it', async () => {
|
|
||||||
const { mock, onClose } = open({ 'POST /api/jobs': FAR, 'DELETE /api/pending/p1': undefined })
|
|
||||||
await arm()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Cancel countdown' }))
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
|
||||||
expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' }))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closing the dialog while armed drops the countdown', async () => {
|
|
||||||
const { mock, onClose } = open({ 'POST /api/jobs': FAR, 'DELETE /api/pending/p1': undefined })
|
|
||||||
await arm()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Close' }))
|
|
||||||
expect(onClose).toHaveBeenCalled()
|
|
||||||
expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' }))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('navigating away while armed drops it too, even if the server refuses', async () => {
|
|
||||||
const { mock, onClose, router } = open({ 'POST /api/jobs': FAR, 'DELETE /api/pending/p1': apiError(404, 'not_found') })
|
|
||||||
await arm()
|
|
||||||
await screen.findByRole('button', { name: 'Cancel countdown' })
|
|
||||||
await router.navigate({ to: '/jobs' })
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
|
||||||
expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' }))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('after a commit, closing no longer drops anything', async () => {
|
|
||||||
const { mock, onClose } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1' }) })
|
|
||||||
await arm()
|
|
||||||
await userEvent.type(await screen.findByLabelText('Retype the phrase to commit'), 'reset gaming now')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Commit' }))
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Close (job keeps running)' }))
|
|
||||||
expect(onClose).toHaveBeenCalled()
|
|
||||||
expect(bodiesOf(mock, 'DELETE /api/pending/p1')).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('arming again after a lapse returns to an empty form', async () => {
|
|
||||||
const lapsing = makePending({ ...FAR, heartbeat_interval_seconds: 0.001 })
|
|
||||||
open({ 'POST /api/jobs': lapsing, 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed') })
|
|
||||||
await arm()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Arm again' }, { timeout: 3000 }))
|
|
||||||
expect(await screen.findByLabelText('Type the phrase to confirm')).toHaveValue('')
|
|
||||||
expect(screen.getByRole('button', { name: 'Arm countdown' })).toBeDisabled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { queryClient } from '../api/queries'
|
|
||||||
import { routes } from '../test/dialogRoutes'
|
|
||||||
import { makeJob, makeSpec } from '../test/fixtures'
|
|
||||||
import { apiError, bodiesOf, installEventSource, renderRouted, Reply, stubApi } from '../test/harness'
|
|
||||||
import { RunnerDialog } from './RunnerDialog'
|
|
||||||
|
|
||||||
function open(name: Parameters<typeof RunnerDialog>[0]['name'], preset?: Record<string, number | string>) {
|
|
||||||
const onClose = vi.fn()
|
|
||||||
const view = renderRouted(<RunnerDialog name={name} preset={preset} onClose={onClose} />)
|
|
||||||
return { onClose, ...view }
|
|
||||||
}
|
|
||||||
const run = (label: string | RegExp) => screen.findByRole('button', { name: label })
|
|
||||||
|
|
||||||
describe('RunnerDialog', () => {
|
|
||||||
beforeEach(installEventSource)
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
|
||||||
|
|
||||||
it('shows loading, then the load error', async () => {
|
|
||||||
let fail: (r: unknown) => void = () => {}
|
|
||||||
stubApi({ 'GET /api/commands': () => new Promise((r) => (fail = r)) })
|
|
||||||
open('scan')
|
|
||||||
expect(await screen.findByText(/Loading commands/)).toBeInTheDocument()
|
|
||||||
await act(async () => fail(apiError(500, 'op_failed', 'catalog broken')))
|
|
||||||
expect(await screen.findByText('catalog broken')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('says so when the server does not offer the command', async () => {
|
|
||||||
stubApi(routes([makeSpec('done')]))
|
|
||||||
open('scan')
|
|
||||||
expect(await screen.findByText('Not in the server catalog')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/does not offer "scan"/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('describes the command with its tags', async () => {
|
|
||||||
stubApi(routes([makeSpec('enforce', { description: 'Enforce.', privileged: true, mutating: false, cancellable: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })]))
|
|
||||||
open('enforce')
|
|
||||||
expect(await screen.findByText('Enforce.')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Runs as root via the daemon')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Read-only')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Cancellable')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('5 min countdown')).toBeInTheDocument()
|
|
||||||
expect(await run('Arm countdown')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs the enforce preset it was opened for, with no mode field to change it', async () => {
|
|
||||||
const demo = { name: 'demo', label: 'Demo mode', type: 'int' as const, required: false, default: 0, min: 0, max: 1 }
|
|
||||||
const mock = stubApi(routes([makeSpec('enforce', { kind: 'screen', params: [demo], privileged: true, cancellable: true })], { 'POST /api/jobs': makeJob({ id: 'j1' }) }))
|
|
||||||
open('enforce', { demo: 1 })
|
|
||||||
const button = await run('Run enforce')
|
|
||||||
expect(screen.getByText('Cancellable')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByLabelText(/Demo mode/)).toBeNull()
|
|
||||||
expect(screen.queryByText('Runs as root via the daemon')).toBeNull()
|
|
||||||
await userEvent.click(button)
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'enforce', params: { demo: 1 } }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('tags a state-changing command, with no countdown for an immediate one', async () => {
|
|
||||||
stubApi(routes([makeSpec('scan', { friction: { phrase_template: 'x' } })]))
|
|
||||||
open('scan')
|
|
||||||
expect(await screen.findByText('Changes state')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/countdown/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs a parameterless command and follows its job', async () => {
|
|
||||||
const mock = stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': makeJob({ id: 'j1' }) }))
|
|
||||||
const { onClose, router } = open('scan')
|
|
||||||
const button = await run('Run scan')
|
|
||||||
expect(button).toHaveFocus()
|
|
||||||
await userEvent.click(button)
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'scan', params: {} }])
|
|
||||||
expect(await screen.findByRole('region', { name: 'Job Scan' })).toBeInTheDocument()
|
|
||||||
await userEvent.click(screen.getByRole('link', { name: 'Open in Jobs' }))
|
|
||||||
expect(router.state.location.pathname).toBe('/jobs/j1')
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled()) // navigating away closes it
|
|
||||||
})
|
|
||||||
|
|
||||||
it('"Close" leaves the job running', async () => {
|
|
||||||
stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': makeJob({ id: 'j1' }) }))
|
|
||||||
const { onClose } = open('scan')
|
|
||||||
await userEvent.click(await run('Run scan'))
|
|
||||||
await userEvent.click(await run('Close (job keeps running)'))
|
|
||||||
expect(onClose).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Cancel closes the form', async () => {
|
|
||||||
stubApi(routes([makeSpec('scan')]))
|
|
||||||
const { onClose } = open('scan')
|
|
||||||
await userEvent.click(await run('Cancel'))
|
|
||||||
expect(onClose).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('parameters', () => {
|
|
||||||
const spec = makeSpec('abandon-pick', {
|
|
||||||
params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }],
|
|
||||||
})
|
|
||||||
|
|
||||||
it('blocks an invalid submit and shows why', async () => {
|
|
||||||
const mock = stubApi(routes([spec]))
|
|
||||||
open('abandon-pick')
|
|
||||||
// The button is disabled while invalid, so only a forced submit (e.g. a
|
|
||||||
// script) reaches the validation display.
|
|
||||||
expect(await run('Run abandon-pick')).toBeDisabled()
|
|
||||||
fireEvent.submit(screen.getByRole('button', { name: 'Run abandon-pick' }).closest('form')!)
|
|
||||||
expect(await screen.findByText('Game is required.')).toBeInTheDocument()
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('starts from a preset and sends typed values', async () => {
|
|
||||||
const mock = stubApi(routes([spec], { 'POST /api/jobs': makeJob({ id: 'j1' }) }))
|
|
||||||
open('abandon-pick', { app_id: 7 })
|
|
||||||
expect(await screen.findByLabelText(/Game/)).toHaveValue('7')
|
|
||||||
await waitFor(() => expect(document.querySelector('.field-help')).toHaveTextContent('Seven')) // useGameLookup
|
|
||||||
await userEvent.click(await run('Run abandon-pick'))
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'abandon-pick', params: { app_id: 7 } }])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a parameter that appears when the catalog refreshes under an open form', async () => {
|
|
||||||
const alphaSpec = makeSpec('hide', { params: [{ name: 'a', label: 'Alpha', type: 'int', required: false }] })
|
|
||||||
const table = routes([alphaSpec])
|
|
||||||
stubApi(table)
|
|
||||||
open('hide')
|
|
||||||
await screen.findByLabelText('Alpha')
|
|
||||||
table['GET /api/commands'] = [
|
|
||||||
makeSpec('hide', {
|
|
||||||
params: [
|
|
||||||
{ name: 'a', label: 'Alpha', type: 'int', required: false },
|
|
||||||
{ name: 'b', label: 'Beta', type: 'int', required: true },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
await act(async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['commands'] })
|
|
||||||
})
|
|
||||||
expect(await screen.findByLabelText(/Beta/)).toHaveValue(null)
|
|
||||||
expect(await run('Run hide')).toBeDisabled() // Beta is required and empty
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('friction', () => {
|
|
||||||
const spec = makeSpec('uninstall', {
|
|
||||||
params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }],
|
|
||||||
friction: { phrase_template: 'uninstall {game_name} and {count} others' },
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the exact phrase once its fields are known, and sends it', async () => {
|
|
||||||
const mock = stubApi(routes([spec], { 'POST /api/jobs': makeJob({ id: 'j1' }) }))
|
|
||||||
open('uninstall')
|
|
||||||
expect(await screen.findByText(/Fill in the fields above to see the exact phrase \(game_name\)/)).toBeInTheDocument()
|
|
||||||
await userEvent.type(screen.getByLabelText(/Game/), '7')
|
|
||||||
const submit = await run('Run uninstall')
|
|
||||||
expect(submit).toBeDisabled()
|
|
||||||
expect(submit).toHaveClass('btn-danger')
|
|
||||||
await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), 'uninstall Seven and 1 others')
|
|
||||||
expect(submit).toBeEnabled()
|
|
||||||
await userEvent.click(submit)
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([
|
|
||||||
{ command: 'uninstall', params: { app_id: 7 }, confirm_phrase: 'uninstall Seven and 1 others' },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('focuses the phrase straight away when there are no fields', async () => {
|
|
||||||
stubApi(routes([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })]))
|
|
||||||
open('reset')
|
|
||||||
expect(await screen.findByLabelText('Type the phrase to confirm')).toHaveFocus()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not submit a wrong phrase on Enter', async () => {
|
|
||||||
const mock = stubApi(routes([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })]))
|
|
||||||
open('reset')
|
|
||||||
await userEvent.type(await screen.findByLabelText('Type the phrase to confirm'), 'nope{Enter}')
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is read-only when locked, and explains', async () => {
|
|
||||||
stubApi(routes([makeSpec('pick', { locked_reason: 'Manual pick active', friction: { phrase_template: 'x' }, params: [{ name: 'n', label: 'N', type: 'int', required: false }] })]))
|
|
||||||
open('pick')
|
|
||||||
expect(await screen.findByText('Manual pick active')).toBeInTheDocument()
|
|
||||||
expect(screen.getByLabelText('N')).toBeDisabled()
|
|
||||||
expect(screen.queryByLabelText('Type the phrase to confirm')).toBeNull()
|
|
||||||
expect(await run('Run pick')).toBeDisabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('start failures', () => {
|
|
||||||
it('shows the server error', async () => {
|
|
||||||
stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': apiError(409, 'busy', 'job 4 running') }))
|
|
||||||
open('scan')
|
|
||||||
await userEvent.click(await run('Run scan'))
|
|
||||||
expect(await screen.findByText('job 4 running')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/terminal instead/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('offers the sudo fallback when the daemon is down', async () => {
|
|
||||||
stubApi(routes([makeSpec('gaming-unblock')], { 'POST /api/jobs': apiError(502, 'daemon_unreachable') }))
|
|
||||||
open('gaming-unblock')
|
|
||||||
await userEvent.click(await run('Run gaming-unblock'))
|
|
||||||
expect(await screen.findByText('Run it from a terminal instead:')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('sudo ./run.sh gaming-unblock')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('has no fallback for a command without one', async () => {
|
|
||||||
stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': apiError(502, 'daemon_unreachable') }))
|
|
||||||
open('scan')
|
|
||||||
await userEvent.click(await run('Run scan'))
|
|
||||||
expect(await screen.findByText('Root daemon unreachable')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/terminal instead/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('copes with a garbled reply', async () => {
|
|
||||||
stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': new Reply(200, undefined, '{garbled') }))
|
|
||||||
open('scan')
|
|
||||||
await userEvent.click(await run('Run scan'))
|
|
||||||
expect(await screen.findByText('Unexpected server error')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Starting… while waiting', async () => {
|
|
||||||
stubApi(routes([makeSpec('scan')], { 'POST /api/jobs': () => new Promise(() => {}) }))
|
|
||||||
open('scan')
|
|
||||||
await userEvent.click(await run('Run scan'))
|
|
||||||
expect(await run('Starting…')).toBeDisabled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
||||||
import { Link, useRouter } from '@tanstack/react-router'
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import { api, ApiFailure, startJob } from '../api/client'
|
|
||||||
import type { CommandName, CommandSpec } from '../api/contract'
|
|
||||||
import type { PendingAction } from '../api/jobContract'
|
|
||||||
import { q, queryClient } from '../api/queries'
|
|
||||||
import { JobProgress } from '../jobs/JobProgress'
|
|
||||||
import { Dialog } from '../ui/Dialog'
|
|
||||||
import { CommandLine, ErrorNotice, Loading, Notice } from '../ui/Notice'
|
|
||||||
import { commandLabel, fillPhrase, findCommand, phraseMatches } from './catalog'
|
|
||||||
import { enforcePreset } from './enforcePreset'
|
|
||||||
import { PendingCountdown } from './PendingCountdown'
|
|
||||||
import { ParamsForm } from './ParamsForm'
|
|
||||||
import { initialValues, paramError, toParams } from './params'
|
|
||||||
import { PhraseField } from './PhraseField'
|
|
||||||
import { useGameLookup } from './useGameLookup'
|
|
||||||
|
|
||||||
type Phase = { k: 'form' } | { k: 'pending'; pending: PendingAction } | { k: 'job'; id: string }
|
|
||||||
|
|
||||||
/** Recovery commands the UI offers when the root daemon is down. */
|
|
||||||
const FALLBACK: Partial<Record<CommandName, string>> = {
|
|
||||||
'gaming-unblock': 'sudo ./run.sh gaming-unblock',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
name: CommandName
|
|
||||||
preset?: Record<string, number | string>
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RunnerDialog({ name, preset, onClose }: Props) {
|
|
||||||
const router = useRouter()
|
|
||||||
const { data: commands, error: loadError } = useQuery(q.commands)
|
|
||||||
const spec = findCommand(commands, name)
|
|
||||||
// An armed countdown dies with the dialog: close, Escape and any
|
|
||||||
// navigation all drop it server-side.
|
|
||||||
const armed = useRef<string | null>(null)
|
|
||||||
const close = useCallback(() => {
|
|
||||||
if (armed.current) void api.dropPending(armed.current).catch(() => {})
|
|
||||||
armed.current = null
|
|
||||||
onClose()
|
|
||||||
}, [onClose])
|
|
||||||
useEffect(() => router.subscribe('onBeforeNavigate', close), [router, close])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open title={commandLabel(name)} onClose={close} wide>
|
|
||||||
{spec ? (
|
|
||||||
<Runner {...enforcePreset(spec, preset)} preset={preset} onClose={close} onArmed={(id) => (armed.current = id)} />
|
|
||||||
) : loadError ? (
|
|
||||||
<ErrorNotice error={loadError} />
|
|
||||||
) : commands ? (
|
|
||||||
<Notice tone="danger" title="Not in the server catalog">The server does not offer "{name}".</Notice>
|
|
||||||
) : (
|
|
||||||
<Loading what="commands" />
|
|
||||||
)}
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RunnerProps {
|
|
||||||
spec: CommandSpec
|
|
||||||
/** Params sent as-is, outside the form (see enforcePreset). */
|
|
||||||
fixed: Record<string, number | string>
|
|
||||||
preset?: Record<string, number | string>
|
|
||||||
onClose: () => void
|
|
||||||
onArmed: (pendingId: string | null) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function Runner({ spec, fixed, preset, onClose, onArmed }: RunnerProps) {
|
|
||||||
const { games, ctx } = useGameLookup()
|
|
||||||
const [values, setValues] = useState(() => initialValues(spec.params, preset))
|
|
||||||
const [typed, setTyped] = useState('')
|
|
||||||
const [showErrors, setShowErrors] = useState(false)
|
|
||||||
const [phase, setPhase] = useState<Phase>({ k: 'form' })
|
|
||||||
|
|
||||||
const start = useMutation({
|
|
||||||
mutationFn: startJob,
|
|
||||||
onSuccess: (out) => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ['jobs'] })
|
|
||||||
onArmed(out.kind === 'pending' ? out.pending.id : null)
|
|
||||||
setPhase(out.kind === 'job' ? { k: 'job', id: out.job.id } : { k: 'pending', pending: out.pending })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const params = { ...fixed, ...toParams(spec.params, values) }
|
|
||||||
const errors = spec.params.map((p) => paramError(p, values[p.name] ?? '', games)).filter(Boolean)
|
|
||||||
const phrase = spec.friction ? fillPhrase(spec.friction.phrase_template, params, ctx) : null
|
|
||||||
const countdown = spec.friction?.countdown_seconds
|
|
||||||
const locked = spec.locked_reason !== null
|
|
||||||
const canSubmit =
|
|
||||||
!locked && errors.length === 0 && (!phrase || (phrase.missing.length === 0 && phraseMatches(phrase.text, typed)))
|
|
||||||
|
|
||||||
const submit = () => {
|
|
||||||
setShowErrors(true)
|
|
||||||
if (!canSubmit) return
|
|
||||||
// Every friction command sends its phrase here; a countdown one
|
|
||||||
// (gaming-reset) needs it to arm and is asked again at commit.
|
|
||||||
start.mutate({ command: spec.name, params, ...(phrase ? { confirm_phrase: typed } : {}) })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase.k === 'job') {
|
|
||||||
return (
|
|
||||||
<div className="stack">
|
|
||||||
<JobProgress jobId={phase.id} />
|
|
||||||
<div className="actions">
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
|
||||||
Close (job keeps running)
|
|
||||||
</button>
|
|
||||||
<Link to="/jobs/$jobId" params={{ jobId: phase.id }} className="btn btn-ghost">
|
|
||||||
Open in Jobs
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (phase.k === 'pending') {
|
|
||||||
return (
|
|
||||||
<PendingCountdown
|
|
||||||
pending={phase.pending}
|
|
||||||
onCommitted={(job) => {
|
|
||||||
onArmed(null)
|
|
||||||
setPhase({ k: 'job', id: job.id })
|
|
||||||
}}
|
|
||||||
onCancel={() => {
|
|
||||||
onArmed(null)
|
|
||||||
onClose()
|
|
||||||
}}
|
|
||||||
onRearm={() => {
|
|
||||||
onArmed(null)
|
|
||||||
setTyped('')
|
|
||||||
setPhase({ k: 'form' })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const failure = start.error instanceof ApiFailure ? start.error : null
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="stack"
|
|
||||||
noValidate
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
submit()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="m0">{spec.description}</p>
|
|
||||||
<div className="tags">
|
|
||||||
{spec.privileged && <span className="pill pill-neutral">Runs as root via the daemon</span>}
|
|
||||||
{spec.mutating ? <span className="pill pill-neutral">Changes state</span> : <span className="pill pill-neutral">Read-only</span>}
|
|
||||||
{spec.cancellable && <span className="pill pill-neutral">Cancellable</span>}
|
|
||||||
{countdown ? <span className="pill pill-warning">{Math.round(countdown / 60)} min countdown</span> : null}
|
|
||||||
</div>
|
|
||||||
{locked && <Notice tone="warning" title="Locked">{spec.locked_reason}</Notice>}
|
|
||||||
<ParamsForm specs={spec.params} values={values} onChange={setValues} games={games} showErrors={showErrors} disabled={locked} />
|
|
||||||
{phrase && phrase.missing.length > 0 && (
|
|
||||||
<Notice tone="info">Fill in the fields above to see the exact phrase ({phrase.missing.join(', ')}).</Notice>
|
|
||||||
)}
|
|
||||||
{phrase && phrase.missing.length === 0 && !locked && (
|
|
||||||
<PhraseField phrase={phrase.text} value={typed} onChange={setTyped} autoFocus={spec.params.length === 0} />
|
|
||||||
)}
|
|
||||||
{start.error && (
|
|
||||||
<ErrorNotice error={start.error}>
|
|
||||||
{failure?.code === 'daemon_unreachable' && FALLBACK[spec.name] && (
|
|
||||||
<>
|
|
||||||
<p className="m0">Run it from a terminal instead:</p>
|
|
||||||
<CommandLine command={FALLBACK[spec.name]!} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ErrorNotice>
|
|
||||||
)}
|
|
||||||
<div className="actions">
|
|
||||||
<button type="submit" className={phrase ? 'btn btn-danger' : 'btn btn-primary'} disabled={!canSubmit || start.isPending} autoFocus={!phrase && spec.params.every((p) => p.type === 'bool')}>
|
|
||||||
{start.isPending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${spec.name}`}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { routes } from '../test/dialogRoutes'
|
|
||||||
import { makeSpec } from '../test/fixtures'
|
|
||||||
import { renderRouted, stubApi } from '../test/harness'
|
|
||||||
import { RunnerProvider } from './RunnerProvider'
|
|
||||||
import { useRunner } from './runnerContext'
|
|
||||||
|
|
||||||
function Launcher() {
|
|
||||||
const { run, openPalette } = useRunner()
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button type="button" onClick={() => run('scan')}>run scan</button>
|
|
||||||
<button type="button" onClick={() => run('done', { n: 1 })}>run done</button>
|
|
||||||
<button type="button" onClick={openPalette}>open palette</button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function mount() {
|
|
||||||
stubApi(routes([makeSpec('scan'), makeSpec('done')]))
|
|
||||||
return renderRouted(
|
|
||||||
<RunnerProvider>
|
|
||||||
<Launcher />
|
|
||||||
</RunnerProvider>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('RunnerProvider', () => {
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
|
||||||
|
|
||||||
it('opens the runner dialog for a command, and closes it', async () => {
|
|
||||||
mount()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'run scan' }))
|
|
||||||
expect(await screen.findByRole('dialog', { name: 'Scan' })).toBeInTheDocument()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
|
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('starts a fresh form each time the same command is run again', async () => {
|
|
||||||
mount()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'run done' }))
|
|
||||||
expect(await screen.findByRole('dialog', { name: 'Done' })).toBeInTheDocument()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }))
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'run done' }))
|
|
||||||
expect(await screen.findByRole('dialog', { name: 'Done' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opens the palette from a button and from Ctrl+K, which toggles it', async () => {
|
|
||||||
mount()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'open palette' }))
|
|
||||||
expect(await screen.findByRole('dialog', { name: 'Command palette' })).toBeInTheDocument()
|
|
||||||
await userEvent.keyboard('{Control>}k{/Control}')
|
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
||||||
await userEvent.keyboard('{Meta>}K{/Meta}')
|
|
||||||
expect(await screen.findByRole('dialog', { name: 'Command palette' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('closes the palette from its own close button', async () => {
|
|
||||||
mount()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'open palette' }))
|
|
||||||
await screen.findByRole('dialog', { name: 'Command palette' })
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Close' }))
|
|
||||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('ignores a plain k, and Ctrl+K while a wide dialog owns the screen', async () => {
|
|
||||||
mount()
|
|
||||||
await screen.findByRole('button', { name: 'run scan' })
|
|
||||||
await userEvent.keyboard('k')
|
|
||||||
expect(screen.queryByRole('dialog')).toBeNull()
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'run scan' }))
|
|
||||||
await screen.findByRole('dialog', { name: 'Scan' })
|
|
||||||
await userEvent.keyboard('{Control>}k{/Control}')
|
|
||||||
expect(screen.queryByRole('dialog', { name: 'Command palette' })).toBeNull()
|
|
||||||
expect(screen.getByRole('dialog', { name: 'Scan' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('running a command from the palette swaps the palette for the runner', async () => {
|
|
||||||
mount()
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'open palette' }))
|
|
||||||
await userEvent.click(await screen.findByText('Does scan.'))
|
|
||||||
expect(await screen.findByRole('dialog', { name: 'Scan' })).toBeInTheDocument()
|
|
||||||
expect(screen.queryByRole('dialog', { name: 'Command palette' })).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stops listening for Ctrl+K when unmounted', async () => {
|
|
||||||
const view = mount()
|
|
||||||
await screen.findByRole('button', { name: 'run scan' })
|
|
||||||
view.unmount()
|
|
||||||
await userEvent.keyboard('{Control>}k{/Control}')
|
|
||||||
expect(screen.queryByRole('dialog')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
|
|
||||||
import type { CommandName } from '../api/contract'
|
|
||||||
import { CommandPalette } from './CommandPalette'
|
|
||||||
import { RunnerContext } from './runnerContext'
|
|
||||||
import { RunnerDialog } from './RunnerDialog'
|
|
||||||
|
|
||||||
interface Open {
|
|
||||||
name: CommandName
|
|
||||||
preset?: Record<string, number | string>
|
|
||||||
/** Changes per open so re-running the same command starts a fresh form. */
|
|
||||||
nonce: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Owns the one runner dialog and the command palette (Ctrl/Cmd+K). */
|
|
||||||
export function RunnerProvider({ children }: { children: ReactNode }) {
|
|
||||||
const [open, setOpen] = useState<Open | null>(null)
|
|
||||||
const [palette, setPalette] = useState(false)
|
|
||||||
|
|
||||||
const run = useCallback((name: CommandName, preset?: Record<string, number | string>) => {
|
|
||||||
setPalette(false)
|
|
||||||
setOpen({ name, preset, nonce: Date.now() })
|
|
||||||
}, [])
|
|
||||||
const openPalette = useCallback(() => setPalette(true), [])
|
|
||||||
const close = useCallback(() => setOpen(null), [])
|
|
||||||
const api = useMemo(() => ({ run, openPalette }), [run, openPalette])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
|
|
||||||
e.preventDefault()
|
|
||||||
// A modal runner keeps focus; the palette waits until it closes.
|
|
||||||
if (!document.querySelector('dialog[open].dialog-wide')) setPalette((p) => !p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', onKey)
|
|
||||||
return () => window.removeEventListener('keydown', onKey)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RunnerContext.Provider value={api}>
|
|
||||||
{children}
|
|
||||||
{palette && <CommandPalette onClose={() => setPalette(false)} onRun={run} />}
|
|
||||||
{open && <RunnerDialog key={open.nonce} name={open.name} preset={open.preset} onClose={close} />}
|
|
||||||
</RunnerContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -80,7 +80,7 @@ export function fillPhrase(
|
|||||||
const text = template.replace(/\{(\w+)\}/g, (whole, key: string) => {
|
const text = template.replace(/\{(\w+)\}/g, (whole, key: string) => {
|
||||||
let value: string | null = null
|
let value: string | null = null
|
||||||
if (key in params && params[key] !== '') value = String(params[key])
|
if (key in params && params[key] !== '') value = String(params[key])
|
||||||
else if (key === 'game_name' && params.app_id !== undefined) value = ctx.gameName(Number(params.app_id))
|
else if (key === 'game_name' && 'app_id' in params) value = ctx.gameName(Number(params.app_id))
|
||||||
else if (key === 'count' && ctx.installed) {
|
else if (key === 'count' && ctx.installed) {
|
||||||
value = String(ctx.installed.filter((g) => !g.assigned && !g.protected).length)
|
value = String(ctx.installed.filter((g) => !g.assigned && !g.protected).length)
|
||||||
}
|
}
|
||||||
|
|||||||
143
web/src/commands/commandButton.test.ts
Normal file
143
web/src/commands/commandButton.test.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { CommandSpec } from '../api/contract'
|
||||||
|
import { q } from '../api/queries'
|
||||||
|
import { runner } from '../lib/runner'
|
||||||
|
import { makeSpec } from '../test/fixtures'
|
||||||
|
import { mount, stubApi } from '../test/harness'
|
||||||
|
import { commandButton } from './commandButton'
|
||||||
|
import { must } from '../test/events'
|
||||||
|
|
||||||
|
const loaded = (specs: CommandSpec[]) => {
|
||||||
|
stubApi({ 'GET /api/commands': specs })
|
||||||
|
}
|
||||||
|
const requests: unknown[] = []
|
||||||
|
let off = (): void => undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
off = runner.onRun((r) => requests.push(r))
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
off()
|
||||||
|
requests.length = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<command-button>', () => {
|
||||||
|
it('runs a job command with its preset when clicked', async () => {
|
||||||
|
loaded([makeSpec('scan')])
|
||||||
|
const el = mount(`<command-button name="scan" preset='{"app_id":4}'></command-button>`)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el.querySelector('button')).toHaveTextContent('Run scan')
|
||||||
|
})
|
||||||
|
const btn = must(el.querySelector('button'))
|
||||||
|
expect(btn).toHaveAttribute('aria-disabled', 'false')
|
||||||
|
expect(btn.className).toBe('btn btn-ghost')
|
||||||
|
btn.click()
|
||||||
|
expect(requests).toEqual([{ name: 'scan', preset: { app_id: 4 } }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is inert until the catalog has loaded', async () => {
|
||||||
|
loaded([])
|
||||||
|
const el = mount('<command-button></command-button>')
|
||||||
|
const btn = must(el.querySelector('button'))
|
||||||
|
expect(btn).toHaveTextContent('Open Status')
|
||||||
|
expect(btn).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
btn.click()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(q.commands.state.loading).toBe(false)
|
||||||
|
})
|
||||||
|
expect(requests).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays focusable but inert while locked, and says why', async () => {
|
||||||
|
loaded([makeSpec('pick', { locked_reason: 'Locked by a manual pick' })])
|
||||||
|
const el = mount('<command-button name="pick" label="Pick it" tone="danger"></command-button>')
|
||||||
|
const btn = must(el.querySelector('button'))
|
||||||
|
expect(btn).toHaveTextContent('Pick it')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el.querySelector('button')).toHaveAttribute('title', 'Locked by a manual pick')
|
||||||
|
})
|
||||||
|
expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
expect(el.querySelector('button')).toHaveClass('btn-danger')
|
||||||
|
el.querySelector('button')?.click()
|
||||||
|
expect(requests).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('links to the owning page for a non-job command', async () => {
|
||||||
|
loaded([makeSpec('status', { kind: 'view' })])
|
||||||
|
const el = mount('<command-button name="status" tone="primary"></command-button>')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el.querySelector('a')).not.toBeNull()
|
||||||
|
})
|
||||||
|
const link = must(el.querySelector('a'))
|
||||||
|
expect(link).toHaveTextContent('Open Status')
|
||||||
|
expect(link).toHaveAttribute('href', '/')
|
||||||
|
expect(link).toHaveClass('btn-primary')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs without a preset when the attribute is absent', async () => {
|
||||||
|
loaded([makeSpec('scan')])
|
||||||
|
const el = mount('<command-button name="scan"></command-button>')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el.querySelector('button')).toHaveTextContent('Run scan')
|
||||||
|
})
|
||||||
|
el.querySelector('button')?.click()
|
||||||
|
expect(requests).toEqual([{ name: 'scan' }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('commandButton()', () => {
|
||||||
|
it('defaults to the ghost tone', () => {
|
||||||
|
expect(commandButton({ name: 'scan' }).className).toBe('btn btn-ghost')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<command-card>', () => {
|
||||||
|
it('shows the label, server description and a run button', async () => {
|
||||||
|
loaded([makeSpec('scan', { description: 'Scans achievements.' })])
|
||||||
|
const el = mount(`<command-card name="scan" preset='{"app_id":4}'></command-card>`)
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el).toHaveTextContent('Scans achievements.')
|
||||||
|
})
|
||||||
|
expect(el.querySelector('h3')).toHaveTextContent('Scan')
|
||||||
|
el.querySelector('button')?.click()
|
||||||
|
expect(requests).toEqual([{ name: 'scan', preset: { app_id: 4 } }])
|
||||||
|
expect(el.querySelector('article')).toHaveClass('cmd-card')
|
||||||
|
expect(el.querySelector('article')).not.toHaveClass('locked')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks privileged and countdown commands', async () => {
|
||||||
|
loaded([makeSpec('gaming-reset', { privileged: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })])
|
||||||
|
const el = mount('<command-card name="gaming-reset"></command-card>')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el).toHaveTextContent('root')
|
||||||
|
})
|
||||||
|
expect(el).toHaveTextContent('countdown')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows no countdown pill for a friction command without one', async () => {
|
||||||
|
loaded([makeSpec('unblock', { friction: { phrase_template: 'x' } })])
|
||||||
|
const el = mount('<command-card name="unblock"></command-card>')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el).toHaveTextContent('Does unblock.')
|
||||||
|
})
|
||||||
|
expect(el).not.toHaveTextContent('countdown')
|
||||||
|
expect(el).not.toHaveTextContent('root')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the lock reason in place of the description', async () => {
|
||||||
|
loaded([makeSpec('pick', { locked_reason: 'Locked by a manual pick' })])
|
||||||
|
const el = mount('<command-card name="pick"></command-card>')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(el.querySelector('.cmd-desc')).toHaveTextContent('Locked by a manual pick')
|
||||||
|
})
|
||||||
|
expect(el.querySelector('article')).toHaveClass('locked')
|
||||||
|
expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a placeholder until the catalog has loaded', () => {
|
||||||
|
loaded([])
|
||||||
|
const el = mount('<command-card name="scan"></command-card>')
|
||||||
|
expect(el.querySelector('.cmd-desc')).toHaveTextContent('…')
|
||||||
|
expect(el.querySelector('button')).toHaveAttribute('aria-disabled', 'true')
|
||||||
|
})
|
||||||
|
})
|
||||||
92
web/src/commands/commandButton.ts
Normal file
92
web/src/commands/commandButton.ts
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
// `<command-button name="…" [preset='{"app_id":1}'] [label] [tone]>` and
|
||||||
|
// `<command-card …>` (was CommandCard.tsx): a catalog command as a button or
|
||||||
|
// a card with its server description and lock reason. A locked button stays
|
||||||
|
// focusable (aria-disabled), so a keyboard user can still reach it and read
|
||||||
|
// why. Non-job commands (views) link to the page that shows them.
|
||||||
|
|
||||||
|
import type { CommandName } from '../api/contract'
|
||||||
|
import { q } from '../api/queries'
|
||||||
|
import { define, h, jsonAttr } from '../lib/dom'
|
||||||
|
import { LiveElement } from '../lib/element'
|
||||||
|
import { pageHref, runner, type Preset } from '../lib/runner'
|
||||||
|
import { pill } from '../ui/build'
|
||||||
|
import { COMMAND_HOME, commandLabel, findCommand } from './catalog'
|
||||||
|
|
||||||
|
export interface ButtonProps {
|
||||||
|
name: CommandName
|
||||||
|
preset?: Preset
|
||||||
|
label?: string
|
||||||
|
tone?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function props(el: Element): ButtonProps {
|
||||||
|
const preset = jsonAttr<Preset | undefined>(el, 'preset', undefined)
|
||||||
|
const label = el.getAttribute('label')
|
||||||
|
return {
|
||||||
|
name: (el.getAttribute('name') ?? 'status') as CommandName,
|
||||||
|
...(preset ? { preset } : {}),
|
||||||
|
...(label ? { label } : {}),
|
||||||
|
tone: el.getAttribute('tone') ?? 'ghost',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The button for one command, from the current catalog. */
|
||||||
|
export function commandButton({ name, preset, label, tone = 'ghost' }: ButtonProps): HTMLElement {
|
||||||
|
const spec = findCommand(q.commands.state.data, name)
|
||||||
|
const locked = spec?.locked_reason ?? null
|
||||||
|
const text = label ?? (spec?.kind === 'job' ? `Run ${name}` : `Open ${commandLabel(name)}`)
|
||||||
|
if (spec && spec.kind !== 'job') return h('a', { href: pageHref(COMMAND_HOME[name]), class: `btn btn-${tone}` }, text)
|
||||||
|
return h(
|
||||||
|
'button',
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
class: `btn btn-${tone}`,
|
||||||
|
'aria-disabled': String(locked !== null || !spec),
|
||||||
|
title: locked,
|
||||||
|
onclick: () => {
|
||||||
|
if (spec && !locked) runner.run(name, preset)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommandButtonElement extends LiveElement {
|
||||||
|
protected override setup(): void {
|
||||||
|
this.watch(q.commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected update(): void {
|
||||||
|
this.replaceChildren(commandButton(props(this)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommandCardElement extends LiveElement {
|
||||||
|
protected override setup(): void {
|
||||||
|
this.watch(q.commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
protected update(): void {
|
||||||
|
const p = props(this)
|
||||||
|
const spec = findCommand(q.commands.state.data, p.name)
|
||||||
|
const locked = spec?.locked_reason ?? null
|
||||||
|
this.replaceChildren(
|
||||||
|
h(
|
||||||
|
'article',
|
||||||
|
{ class: locked ? 'cmd-card locked' : 'cmd-card' },
|
||||||
|
h(
|
||||||
|
'h3',
|
||||||
|
{ class: 'cmd-name' },
|
||||||
|
commandLabel(p.name),
|
||||||
|
spec?.privileged ? pill('neutral', 'root') : null,
|
||||||
|
spec?.friction?.countdown_seconds ? pill('warning', 'countdown') : null,
|
||||||
|
),
|
||||||
|
h('p', { class: 'cmd-desc' }, locked ?? spec?.description ?? '…'),
|
||||||
|
commandButton(p),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
define('command-button', CommandButtonElement)
|
||||||
|
define('command-card', CommandCardElement)
|
||||||
32
web/src/commands/gameLookup.test.ts
Normal file
32
web/src/commands/gameLookup.test.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { makeDataset, makeGame } from '../test/factories'
|
||||||
|
import { makeInstalled, makeStatus } from '../test/fixtures'
|
||||||
|
import { gameLookup } from './gameLookup'
|
||||||
|
|
||||||
|
describe('gameLookup', () => {
|
||||||
|
it('is empty until the sources load', () => {
|
||||||
|
const { games, ctx } = gameLookup(undefined, undefined, undefined)
|
||||||
|
expect(games).toEqual([])
|
||||||
|
expect(ctx.gameName(1)).toBeNull()
|
||||||
|
expect(ctx.installed).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('merges backlog, installed, manual picks and the assignment, sorted by name', () => {
|
||||||
|
const { games, ctx } = gameLookup(
|
||||||
|
makeDataset([makeGame({ app_id: 1, name: 'Zeta' }), makeGame({ app_id: 2, name: 'Old name' })]),
|
||||||
|
{ games: [makeInstalled({ app_id: 2, name: 'Beta' })] },
|
||||||
|
makeStatus({ current_app_id: 4, current_game_name: 'Delta', manual_picks: [{ app_id: 3, name: 'Gamma', age_days: 1 }] }),
|
||||||
|
)
|
||||||
|
expect(games.map((g) => g.name)).toEqual(['Beta', 'Delta', 'Gamma', 'Zeta'])
|
||||||
|
expect(ctx.gameName(2)).toBe('Beta')
|
||||||
|
expect(ctx.installed).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips an unassigned status', () => {
|
||||||
|
const base = [makeGame({ app_id: 1, name: 'Zeta' })]
|
||||||
|
const none = gameLookup(makeDataset(base), undefined, makeStatus({ current_app_id: null, current_game_name: null }))
|
||||||
|
expect(none.games).toHaveLength(1)
|
||||||
|
const nameless = gameLookup(makeDataset(base), undefined, makeStatus({ current_app_id: 9, current_game_name: '' }))
|
||||||
|
expect(nameless.games).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
21
web/src/commands/gameLookup.ts
Normal file
21
web/src/commands/gameLookup.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
// Every game the UI knows a name for (backlog snapshot, installed list,
|
||||||
|
// current assignment, manual picks), for app_id pickers and for filling
|
||||||
|
// `{game_name}` / `{count}` in phrase previews (was useGameLookup.ts).
|
||||||
|
|
||||||
|
import type { InstalledPayload, StatusPayload, WebDataset } from '../api/contract'
|
||||||
|
import type { PhraseContext } from './catalog'
|
||||||
|
import type { GameOption } from './params'
|
||||||
|
|
||||||
|
export function gameLookup(
|
||||||
|
dataset: WebDataset | undefined,
|
||||||
|
installed: InstalledPayload | undefined,
|
||||||
|
status: StatusPayload | undefined,
|
||||||
|
): { games: GameOption[]; ctx: PhraseContext } {
|
||||||
|
const byId = new Map<number, string>()
|
||||||
|
for (const g of dataset?.games ?? []) byId.set(g.app_id, g.name)
|
||||||
|
for (const g of installed?.games ?? []) byId.set(g.app_id, g.name)
|
||||||
|
for (const p of status?.manual_picks ?? []) byId.set(p.app_id, p.name)
|
||||||
|
if (status?.current_app_id && status.current_game_name) byId.set(status.current_app_id, status.current_game_name)
|
||||||
|
const games = [...byId].map(([app_id, name]) => ({ app_id, name })).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
return { games, ctx: { gameName: (id) => byId.get(id) ?? null, installed: installed?.games ?? null } }
|
||||||
|
}
|
||||||
216
web/src/commands/palette.test.ts
Normal file
216
web/src/commands/palette.test.ts
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { NAV } from '../shell/nav'
|
||||||
|
import { runner, type RunRequest } from '../lib/runner'
|
||||||
|
import { must, setValue } from '../test/events'
|
||||||
|
import { makeSpec } from '../test/fixtures'
|
||||||
|
import { apiError, stubApi } from '../test/harness'
|
||||||
|
import { CommandPaletteElement, go } from './palette'
|
||||||
|
|
||||||
|
const SPECS = [
|
||||||
|
makeSpec('scan', { description: 'Scan achievements.' }),
|
||||||
|
makeSpec('unblock', { description: 'Open the store.', friction: { phrase_template: 'x' } }),
|
||||||
|
makeSpec('status', { kind: 'view', description: 'Show status.' }),
|
||||||
|
makeSpec('pick', { description: 'Pick next.', locked_reason: 'Locked by a pick' }),
|
||||||
|
]
|
||||||
|
const TOTAL = SPECS.length + NAV.length
|
||||||
|
|
||||||
|
const assign = vi.fn()
|
||||||
|
const runs: RunRequest[] = []
|
||||||
|
let offRun = (): void => undefined
|
||||||
|
|
||||||
|
function open(specs = SPECS) {
|
||||||
|
stubApi({ 'GET /api/commands': specs })
|
||||||
|
const el = new CommandPaletteElement()
|
||||||
|
document.body.append(el)
|
||||||
|
el.open()
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
const box = () => must(document.querySelector<HTMLInputElement>('[role=combobox]'))
|
||||||
|
const options = () => [...document.querySelectorAll<HTMLElement>('[role=option]')]
|
||||||
|
const active = () => options().findIndex((o) => o.getAttribute('aria-selected') === 'true')
|
||||||
|
const opt = (title: string) => must(options().find((o) => o.querySelector('.palette-title')?.firstChild?.textContent === title))
|
||||||
|
const key = (k: string) => box().dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }))
|
||||||
|
const loaded = () => vi.waitFor(() => {
|
||||||
|
expect(options().length).toBeGreaterThan(0)
|
||||||
|
expect(document.body).not.toHaveTextContent('Loading commands')
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('CommandPaletteElement', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('location', { assign })
|
||||||
|
offRun = runner.onRun((r) => runs.push(r))
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
offRun()
|
||||||
|
runs.length = 0
|
||||||
|
assign.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists every command, then the sections, and focuses the box', async () => {
|
||||||
|
const el = open()
|
||||||
|
await loaded()
|
||||||
|
expect(el.isOpen).toBe(true)
|
||||||
|
expect(options()).toHaveLength(TOTAL)
|
||||||
|
expect(opt('Scan')).toHaveTextContent('run')
|
||||||
|
expect(opt('Unblock')).toHaveTextContent('run · confirm')
|
||||||
|
expect(opt('Status')).toHaveTextContent('open /')
|
||||||
|
expect(opt('Pick')).toHaveTextContent('locked')
|
||||||
|
expect(opt('Pick')).toHaveTextContent('Locked by a pick')
|
||||||
|
expect(opt('Pick')).not.toHaveTextContent('Pick next.')
|
||||||
|
expect(document.body).toHaveTextContent('Go to Dashboard')
|
||||||
|
expect(document.activeElement).toBe(box())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens from the runner bus, closes when a run starts, and cleans up on removal', () => {
|
||||||
|
stubApi({ 'GET /api/commands': SPECS })
|
||||||
|
const el = new CommandPaletteElement()
|
||||||
|
document.body.append(el)
|
||||||
|
runner.openPalette()
|
||||||
|
expect(el.isOpen).toBe(true)
|
||||||
|
runner.run('scan')
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
runner.openPalette()
|
||||||
|
el.remove()
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
runner.openPalette()
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('toggles and ignores a second open', async () => {
|
||||||
|
const el = open()
|
||||||
|
await loaded()
|
||||||
|
el.open()
|
||||||
|
expect(options()).toHaveLength(TOTAL)
|
||||||
|
el.toggle()
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
el.toggle()
|
||||||
|
expect(el.isOpen).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes on Escape and on its close button', () => {
|
||||||
|
const el = open()
|
||||||
|
el.querySelector('dialog')?.dispatchEvent(new Event('cancel', { cancelable: true }))
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
el.open()
|
||||||
|
el.querySelector<HTMLButtonElement>('.dialog-close')?.click()
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows loading, then the load error', async () => {
|
||||||
|
let fail: (r: unknown) => void = () => undefined
|
||||||
|
stubApi({ 'GET /api/commands': () => new Promise((r) => { fail = r }) })
|
||||||
|
const el = new CommandPaletteElement()
|
||||||
|
document.body.append(el)
|
||||||
|
el.open()
|
||||||
|
expect(document.body).toHaveTextContent('Loading commands')
|
||||||
|
fail(apiError(500, 'op_failed', 'catalog broken'))
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(document.body).toHaveTextContent('catalog broken')
|
||||||
|
})
|
||||||
|
expect(document.body).not.toHaveTextContent('Loading commands')
|
||||||
|
expect(options()).toHaveLength(NAV.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters by text and says when nothing matches', async () => {
|
||||||
|
open()
|
||||||
|
await loaded()
|
||||||
|
setValue(box(), 'store')
|
||||||
|
expect(options()).toHaveLength(2)
|
||||||
|
setValue(box(), 'zzz')
|
||||||
|
expect(document.body).toHaveTextContent('No command matches “zzz”.')
|
||||||
|
expect(box()).not.toHaveAttribute('aria-activedescendant')
|
||||||
|
expect(active()).toBe(-1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reopens with an empty box and the first entry active', async () => {
|
||||||
|
const el = open()
|
||||||
|
await loaded()
|
||||||
|
setValue(box(), 'store')
|
||||||
|
el.close()
|
||||||
|
el.open()
|
||||||
|
expect(box()).toHaveValue('')
|
||||||
|
expect(options()).toHaveLength(TOTAL)
|
||||||
|
expect(active()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves with arrows and pages, clamped, and announces the active option', async () => {
|
||||||
|
open()
|
||||||
|
await loaded()
|
||||||
|
expect(key('ArrowDown')).toBe(false)
|
||||||
|
key('ArrowDown')
|
||||||
|
expect(active()).toBe(2)
|
||||||
|
expect(box().getAttribute('aria-activedescendant')).toBe(options()[2]?.id)
|
||||||
|
key('ArrowUp')
|
||||||
|
key('ArrowUp')
|
||||||
|
key('ArrowUp')
|
||||||
|
expect(active()).toBe(0)
|
||||||
|
key('PageDown')
|
||||||
|
expect(active()).toBe(8)
|
||||||
|
key('PageDown')
|
||||||
|
expect(active()).toBe(TOTAL - 1)
|
||||||
|
key('ArrowDown')
|
||||||
|
expect(active()).toBe(TOTAL - 1)
|
||||||
|
key('PageUp')
|
||||||
|
expect(active()).toBe(TOTAL - 9)
|
||||||
|
key('PageUp')
|
||||||
|
key('PageUp')
|
||||||
|
expect(active()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('the pointer moves the highlight, and a typed edit resets it', async () => {
|
||||||
|
open()
|
||||||
|
await loaded()
|
||||||
|
const second = options()[1]
|
||||||
|
second.dispatchEvent(new MouseEvent('mousemove'))
|
||||||
|
expect(active()).toBe(1)
|
||||||
|
expect(second).toHaveClass('active')
|
||||||
|
second.dispatchEvent(new MouseEvent('mousemove'))
|
||||||
|
expect(active()).toBe(1)
|
||||||
|
setValue(box(), 's')
|
||||||
|
expect(active()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter runs a job command', async () => {
|
||||||
|
open()
|
||||||
|
await loaded()
|
||||||
|
expect(key('Enter')).toBe(false)
|
||||||
|
expect(runs).toEqual([{ name: 'scan' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter on a view command navigates and closes', async () => {
|
||||||
|
const el = open()
|
||||||
|
await loaded()
|
||||||
|
setValue(box(), 'show status')
|
||||||
|
key('Enter')
|
||||||
|
expect(runs).toEqual([])
|
||||||
|
expect(assign).toHaveBeenCalledWith('/')
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking a section navigates and closes', async () => {
|
||||||
|
const el = open()
|
||||||
|
await loaded()
|
||||||
|
opt('Go to Jobs').click()
|
||||||
|
expect(assign).toHaveBeenCalledWith('/jobs/')
|
||||||
|
expect(el.isOpen).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter with nothing shown does nothing, and other keys are ignored', async () => {
|
||||||
|
const el = open()
|
||||||
|
await loaded()
|
||||||
|
setValue(box(), 'zzz')
|
||||||
|
expect(key('Enter')).toBe(true)
|
||||||
|
expect(key('Tab')).toBe(true)
|
||||||
|
expect(runs).toEqual([])
|
||||||
|
expect(el.isOpen).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('go', () => {
|
||||||
|
it('loads the section as a static page', () => {
|
||||||
|
vi.stubGlobal('location', { assign })
|
||||||
|
go('/library')
|
||||||
|
expect(assign).toHaveBeenCalledWith('/library/')
|
||||||
|
assign.mockClear()
|
||||||
|
})
|
||||||
|
})
|
||||||
197
web/src/commands/palette.ts
Normal file
197
web/src/commands/palette.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
// `<command-palette>` (Ctrl/Cmd+K): every command from `GET /api/commands`
|
||||||
|
// plus the sections (was CommandPalette.tsx). A combobox + listbox pair:
|
||||||
|
// arrows move, Enter runs, Escape closes, and the active option is announced
|
||||||
|
// via aria-activedescendant.
|
||||||
|
|
||||||
|
import { q } from '../api/queries'
|
||||||
|
import { define, h } from '../lib/dom'
|
||||||
|
import { uid } from '../lib/ids'
|
||||||
|
import { pageHref, runner } from '../lib/runner'
|
||||||
|
import { NAV } from '../shell/nav'
|
||||||
|
import { errorNotice, loading, pill } from '../ui/build'
|
||||||
|
import { Modal } from '../ui/dialog'
|
||||||
|
import { COMMAND_HOME, commandLabel } from './catalog'
|
||||||
|
|
||||||
|
interface Entry {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
hint: string
|
||||||
|
locked: string | null
|
||||||
|
act: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Navigate to a section page (a full page load: this is a static site). */
|
||||||
|
export function go(to: string): void {
|
||||||
|
window.location.assign(pageHref(to))
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommandPaletteElement extends HTMLElement {
|
||||||
|
private readonly modal = new Modal({ title: 'Command palette', onClose: () => { this.close() } })
|
||||||
|
private readonly listId = uid('palette')
|
||||||
|
private readonly input: HTMLInputElement
|
||||||
|
private readonly list: HTMLUListElement
|
||||||
|
private readonly status = h('div', { class: 'contents' })
|
||||||
|
private active = 0
|
||||||
|
private shown: Entry[] = []
|
||||||
|
private unsubs: (() => void)[] = []
|
||||||
|
private unwatch: (() => void) | null = null
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.input = h('input', {
|
||||||
|
class: 'input palette-input',
|
||||||
|
role: 'combobox',
|
||||||
|
'aria-expanded': 'true',
|
||||||
|
'aria-controls': `${this.listId}-list`,
|
||||||
|
'aria-label': 'Search commands',
|
||||||
|
placeholder: 'Type a command or section…',
|
||||||
|
autofocus: true,
|
||||||
|
})
|
||||||
|
this.list = h('ul', { id: `${this.listId}-list`, role: 'listbox', class: 'palette-list', 'aria-label': 'Commands' })
|
||||||
|
this.input.addEventListener('input', () => {
|
||||||
|
this.active = 0
|
||||||
|
this.renderList()
|
||||||
|
})
|
||||||
|
this.input.addEventListener('keydown', (e) => {
|
||||||
|
this.onKey(e)
|
||||||
|
})
|
||||||
|
this.modal.render(this.input, this.status, this.list, h('p', { class: 'field-help' }, '↑↓ to move · Enter to run · Esc to close'))
|
||||||
|
}
|
||||||
|
|
||||||
|
get isOpen(): boolean {
|
||||||
|
return this.modal.isOpen
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.append(this.modal.el)
|
||||||
|
this.unsubs.push(runner.onPalette(() => {
|
||||||
|
this.open()
|
||||||
|
}))
|
||||||
|
this.unsubs.push(runner.onRun(() => {
|
||||||
|
this.close()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.close()
|
||||||
|
for (const u of this.unsubs.splice(0)) u()
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(): void {
|
||||||
|
if (this.isOpen) this.close()
|
||||||
|
else this.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
open(): void {
|
||||||
|
if (this.isOpen) return
|
||||||
|
this.input.value = ''
|
||||||
|
this.active = 0
|
||||||
|
this.unwatch = q.commands.subscribe(() => {
|
||||||
|
this.renderList()
|
||||||
|
})
|
||||||
|
this.renderList()
|
||||||
|
this.modal.show()
|
||||||
|
this.input.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
this.unwatch?.()
|
||||||
|
this.unwatch = null
|
||||||
|
this.modal.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private entries(): Entry[] {
|
||||||
|
return [
|
||||||
|
...(q.commands.state.data ?? []).map((c) => ({
|
||||||
|
key: c.name,
|
||||||
|
title: commandLabel(c.name),
|
||||||
|
detail: c.description,
|
||||||
|
hint: c.kind === 'job' ? (c.friction ? 'run · confirm' : 'run') : `open ${COMMAND_HOME[c.name]}`,
|
||||||
|
locked: c.locked_reason,
|
||||||
|
act: () => {
|
||||||
|
if (c.kind === 'job') runner.run(c.name)
|
||||||
|
else {
|
||||||
|
this.close()
|
||||||
|
go(COMMAND_HOME[c.name])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...NAV.map((n) => ({
|
||||||
|
key: `nav:${n.to}`,
|
||||||
|
title: `Go to ${n.label}`,
|
||||||
|
detail: n.blurb,
|
||||||
|
hint: 'navigate',
|
||||||
|
locked: null,
|
||||||
|
act: () => {
|
||||||
|
this.close()
|
||||||
|
go(n.to)
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderList(): void {
|
||||||
|
const { data, error } = q.commands.state
|
||||||
|
this.status.replaceChildren(...(error ? [errorNotice(error)] : !data ? [loading('commands')] : []))
|
||||||
|
const needle = this.input.value.trim().toLowerCase()
|
||||||
|
const all = this.entries()
|
||||||
|
this.shown = needle ? all.filter((e) => `${e.key} ${e.title} ${e.detail}`.toLowerCase().includes(needle)) : all
|
||||||
|
this.active = Math.min(this.active, Math.max(0, this.shown.length - 1))
|
||||||
|
this.list.replaceChildren(
|
||||||
|
...this.shown.map((e, i) => {
|
||||||
|
const li = h(
|
||||||
|
'li',
|
||||||
|
{ id: `${this.listId}-${i}`, role: 'option', 'aria-selected': String(i === this.active), class: i === this.active ? 'palette-item active' : 'palette-item' },
|
||||||
|
h('span', { class: 'palette-title' }, e.title, e.locked ? pill('warning', 'locked') : null),
|
||||||
|
h('span', { class: 'palette-detail' }, e.locked ?? e.detail),
|
||||||
|
h('span', { class: 'palette-hint' }, e.hint),
|
||||||
|
)
|
||||||
|
li.addEventListener('mousemove', () => {
|
||||||
|
if (this.active !== i) this.setActive(i)
|
||||||
|
})
|
||||||
|
li.addEventListener('click', e.act)
|
||||||
|
return li
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (this.shown.length === 0) this.list.append(h('li', { class: 'empty' }, `No command matches “${this.input.value}”.`))
|
||||||
|
this.syncActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setActive(i: number): void {
|
||||||
|
this.active = i
|
||||||
|
this.list.querySelectorAll('[role=option]').forEach((li, j) => {
|
||||||
|
li.setAttribute('aria-selected', String(j === i))
|
||||||
|
li.className = j === i ? 'palette-item active' : 'palette-item'
|
||||||
|
})
|
||||||
|
this.syncActive()
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncActive(): void {
|
||||||
|
if (this.shown.length > 0) this.input.setAttribute('aria-activedescendant', `${this.listId}-${this.active}`)
|
||||||
|
else this.input.removeAttribute('aria-activedescendant')
|
||||||
|
}
|
||||||
|
|
||||||
|
private onKey(e: KeyboardEvent): void {
|
||||||
|
const last = this.shown.length - 1
|
||||||
|
const moves = new Map<string, number>([
|
||||||
|
['ArrowDown', Math.min(last, this.active + 1)],
|
||||||
|
['ArrowUp', Math.max(0, this.active - 1)],
|
||||||
|
['PageDown', Math.min(last, this.active + 8)],
|
||||||
|
['PageUp', Math.max(0, this.active - 8)],
|
||||||
|
])
|
||||||
|
const to = moves.get(e.key)
|
||||||
|
if (to !== undefined) {
|
||||||
|
e.preventDefault()
|
||||||
|
this.setActive(to)
|
||||||
|
document.getElementById(`${this.listId}-${to}`)?.scrollIntoView({ block: 'nearest' })
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
const entry = this.shown.at(this.active)
|
||||||
|
if (!entry) return
|
||||||
|
e.preventDefault()
|
||||||
|
entry.act()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
define('command-palette', CommandPaletteElement)
|
||||||
@ -43,5 +43,5 @@ export function toParams(specs: ParamSpec[], values: ParamValues): Record<string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initialValues(specs: ParamSpec[], preset: Record<string, number | string> = {}): ParamValues {
|
export function initialValues(specs: ParamSpec[], preset: Record<string, number | string> = {}): ParamValues {
|
||||||
return Object.fromEntries(specs.map((p) => [p.name, String(preset[p.name] ?? p.default ?? '')]))
|
return Object.fromEntries(specs.map((p) => [p.name, String((p.name in preset ? preset[p.name] : undefined) ?? p.default ?? '')]))
|
||||||
}
|
}
|
||||||
|
|||||||
134
web/src/commands/paramsForm.test.ts
Normal file
134
web/src/commands/paramsForm.test.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { ParamSpec } from '../api/contract'
|
||||||
|
import type { ParamValues } from './params'
|
||||||
|
import { ParamsForm } from './paramsForm'
|
||||||
|
import { must } from '../test/events'
|
||||||
|
|
||||||
|
const SPECS: ParamSpec[] = [
|
||||||
|
{ name: 'app_id', label: 'Game', type: 'app_id', required: true },
|
||||||
|
{ name: 'days', label: 'Days', type: 'int', required: false, min: 1, max: 9, help: 'How long.' },
|
||||||
|
{ name: 'note', label: 'Note', type: 'string', required: false },
|
||||||
|
]
|
||||||
|
const GAMES = [{ app_id: 5, name: 'Five' }]
|
||||||
|
|
||||||
|
function make(over: { specs?: ParamSpec[]; values?: ParamValues; disabled?: boolean } = {}) {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const form = new ParamsForm({
|
||||||
|
specs: over.specs ?? SPECS,
|
||||||
|
values: over.values ?? { app_id: '', days: '', note: '' },
|
||||||
|
disabled: over.disabled ?? false,
|
||||||
|
onChange,
|
||||||
|
})
|
||||||
|
document.body.append(...form.nodes)
|
||||||
|
const input = (label: RegExp | string) => must(document.querySelector<HTMLInputElement>(`#${labelFor(label)}`))
|
||||||
|
return { form, onChange, input }
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(text: RegExp | string): string {
|
||||||
|
const label = [...document.querySelectorAll('label')].find((l) => (typeof text === 'string' ? l.textContent === text : text.test(l.textContent)))
|
||||||
|
return label?.getAttribute('for') ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ParamsForm', () => {
|
||||||
|
it('renders one labelled field per spec, marking the required ones', () => {
|
||||||
|
const { input } = make()
|
||||||
|
expect(input(/Game/)).toHaveAttribute('list')
|
||||||
|
expect(input(/Game/)).toHaveAttribute('inputmode', 'numeric')
|
||||||
|
expect(input(/Game/)).toHaveAttribute('autofocus')
|
||||||
|
expect(input(/Game/)).toBeRequired()
|
||||||
|
expect(document.body).toHaveTextContent('Game *')
|
||||||
|
expect(input('Days')).toHaveAttribute('type', 'number')
|
||||||
|
expect(input('Days')).toHaveAttribute('min', '1')
|
||||||
|
expect(input('Days')).toHaveAttribute('max', '9')
|
||||||
|
expect(input('Days')).not.toHaveAttribute('autofocus')
|
||||||
|
expect(input('Note')).toHaveAttribute('type', 'text')
|
||||||
|
expect(input('Note')).not.toHaveAttribute('inputmode')
|
||||||
|
expect(input('Note')).not.toHaveAttribute('list')
|
||||||
|
expect(document.body).toHaveTextContent('Type an app id or pick a game from the list.')
|
||||||
|
expect(document.body).toHaveTextContent('How long.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the known games in a datalist and names the chosen one', async () => {
|
||||||
|
const { form, input } = make()
|
||||||
|
form.refresh(GAMES)
|
||||||
|
expect(document.querySelector('datalist option')).toHaveValue('5')
|
||||||
|
expect(document.querySelector('datalist option')).toHaveTextContent('Five')
|
||||||
|
await userEvent.type(input(/Game/), '5')
|
||||||
|
expect(document.querySelector('.field-help')).toHaveTextContent('Five')
|
||||||
|
form.refresh(GAMES)
|
||||||
|
expect(document.querySelectorAll('datalist option')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows errors only once asked to', () => {
|
||||||
|
const { form, input } = make()
|
||||||
|
expect(document.body).not.toHaveTextContent('Game is required.')
|
||||||
|
form.refresh(GAMES, true)
|
||||||
|
expect(document.body).toHaveTextContent('Game is required.')
|
||||||
|
expect(input(/Game/)).toHaveClass('input-bad')
|
||||||
|
expect(input(/Game/)).toHaveAttribute('aria-invalid', 'true')
|
||||||
|
expect(document.querySelector('.field-help')).toHaveClass('text-danger')
|
||||||
|
form.refresh(GAMES, false)
|
||||||
|
expect(input(/Game/)).not.toHaveClass('input-bad')
|
||||||
|
expect(input(/Game/)).toHaveAttribute('aria-invalid', 'false')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the error setting across a games-only refresh', () => {
|
||||||
|
const { form } = make()
|
||||||
|
form.refresh(GAMES, true)
|
||||||
|
form.refresh([{ app_id: 6, name: 'Six' }])
|
||||||
|
expect(document.body).toHaveTextContent('Game is required.')
|
||||||
|
form.refresh()
|
||||||
|
expect(document.body).toHaveTextContent('Game is required.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats a value the form has not set yet as empty', () => {
|
||||||
|
const { input, form } = make({ values: {} })
|
||||||
|
expect(input('Note')).toHaveValue('')
|
||||||
|
form.refresh([], true)
|
||||||
|
expect(document.body).toHaveTextContent('Game is required.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports every edit with the full value set', async () => {
|
||||||
|
const { input, onChange } = make()
|
||||||
|
await userEvent.type(input('Note'), 'hi')
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ app_id: '', days: '', note: 'hi' })
|
||||||
|
await userEvent.type(input('Days'), '3')
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ app_id: '', days: '3', note: 'hi' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables every field', () => {
|
||||||
|
const { input } = make({ disabled: true })
|
||||||
|
expect(input('Note')).toBeDisabled()
|
||||||
|
expect(input(/Game/)).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a bool as a checkbox that toggles 0/1', async () => {
|
||||||
|
const box: ParamSpec[] = [{ name: 'replace', label: 'Replace', type: 'bool', required: false, default: 0 }]
|
||||||
|
const { input, onChange, form } = make({ specs: box, values: { replace: '0' } })
|
||||||
|
form.refresh([], true)
|
||||||
|
const check = input('Replace')
|
||||||
|
expect(check).toHaveAttribute('type', 'checkbox')
|
||||||
|
expect(check).not.toBeChecked()
|
||||||
|
expect(check).not.toHaveAttribute('autofocus')
|
||||||
|
await userEvent.click(check)
|
||||||
|
expect(check).toBeChecked()
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ replace: '1' })
|
||||||
|
await userEvent.click(check)
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith({ replace: '0' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a bool checked from "1" and shows its help', () => {
|
||||||
|
const box: ParamSpec[] = [{ name: 'on', label: 'On', type: 'bool', required: false, help: 'Be on.' }]
|
||||||
|
const { input } = make({ specs: box, values: { on: '1' } })
|
||||||
|
expect(input('On')).toBeChecked()
|
||||||
|
expect(document.body).toHaveTextContent('Be on.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves a bool without help blank, and a missing bool value unchecked', () => {
|
||||||
|
const box: ParamSpec[] = [{ name: 'on', label: 'On', type: 'bool', required: false }]
|
||||||
|
const { input } = make({ specs: box, values: {} })
|
||||||
|
expect(input('On')).not.toBeChecked()
|
||||||
|
expect(document.querySelector('.field-help')?.textContent).toBe(' ')
|
||||||
|
})
|
||||||
|
})
|
||||||
121
web/src/commands/paramsForm.ts
Normal file
121
web/src/commands/paramsForm.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
// The runner's parameter fields (was ParamsForm.tsx). Inputs are built once
|
||||||
|
// per spec list; `refresh()` only updates validity and help text, so typing
|
||||||
|
// never re-creates an input.
|
||||||
|
|
||||||
|
import type { ParamSpec } from '../api/contract'
|
||||||
|
import { h } from '../lib/dom'
|
||||||
|
import { uid } from '../lib/ids'
|
||||||
|
import { paramError, type GameOption, type ParamValues } from './params'
|
||||||
|
|
||||||
|
interface Field {
|
||||||
|
wrap: HTMLDivElement
|
||||||
|
spec: ParamSpec
|
||||||
|
input: HTMLInputElement
|
||||||
|
help: HTMLParagraphElement
|
||||||
|
datalist: HTMLDataListElement | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParamsFormOptions {
|
||||||
|
specs: ParamSpec[]
|
||||||
|
values: ParamValues
|
||||||
|
disabled: boolean
|
||||||
|
onChange: (values: ParamValues) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParamsForm {
|
||||||
|
readonly nodes: HTMLDivElement[]
|
||||||
|
private readonly fields: Field[]
|
||||||
|
private values: ParamValues
|
||||||
|
private games: GameOption[] = []
|
||||||
|
private showErrors = false
|
||||||
|
|
||||||
|
constructor({ specs, values, disabled, onChange }: ParamsFormOptions) {
|
||||||
|
this.values = { ...values }
|
||||||
|
const base = uid('param')
|
||||||
|
this.fields = specs.map((spec, i) => this.field(spec, `${base}-${spec.name}`, i === 0, disabled, onChange))
|
||||||
|
this.nodes = this.fields.map((f) => f.wrap)
|
||||||
|
this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** New game list (app_id pickers) and/or error visibility. */
|
||||||
|
refresh(games: GameOption[] = this.games, showErrors: boolean = this.showErrors): void {
|
||||||
|
this.games = games
|
||||||
|
this.showErrors = showErrors
|
||||||
|
for (const f of this.fields) this.refreshField(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
private field(
|
||||||
|
spec: ParamSpec,
|
||||||
|
id: string,
|
||||||
|
first: boolean,
|
||||||
|
disabled: boolean,
|
||||||
|
onChange: (values: ParamValues) => void,
|
||||||
|
): Field {
|
||||||
|
const raw = this.values[spec.name] ?? ''
|
||||||
|
const help = h('p', { id: `${id}-help`, class: 'field-help' })
|
||||||
|
const emit = (value: string) => {
|
||||||
|
this.values = { ...this.values, [spec.name]: value }
|
||||||
|
this.refresh()
|
||||||
|
onChange(this.values)
|
||||||
|
}
|
||||||
|
if (spec.type === 'bool') {
|
||||||
|
const input = h('input', { id, type: 'checkbox', disabled, 'aria-describedby': `${id}-help` })
|
||||||
|
input.checked = raw === '1'
|
||||||
|
input.addEventListener('change', () => {
|
||||||
|
emit(input.checked ? '1' : '0')
|
||||||
|
})
|
||||||
|
const wrap = h('div', { class: 'form-field' }, h('label', { class: 'check', for: id }, input, spec.label), help)
|
||||||
|
return { wrap, spec, input, help, datalist: null }
|
||||||
|
}
|
||||||
|
const numeric = spec.type !== 'string'
|
||||||
|
const datalist = spec.type === 'app_id' ? h('datalist', { id: `${id}-games` }) : null
|
||||||
|
const input = h('input', {
|
||||||
|
id,
|
||||||
|
class: numeric ? 'input input-num' : 'input',
|
||||||
|
type: spec.type === 'int' ? 'number' : 'text',
|
||||||
|
inputmode: numeric ? 'numeric' : null,
|
||||||
|
min: spec.min,
|
||||||
|
max: spec.max,
|
||||||
|
list: datalist ? `${id}-games` : null,
|
||||||
|
required: spec.required,
|
||||||
|
disabled,
|
||||||
|
autofocus: first,
|
||||||
|
'aria-describedby': `${id}-help`,
|
||||||
|
})
|
||||||
|
input.value = raw
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
emit(input.value)
|
||||||
|
})
|
||||||
|
const wrap = h(
|
||||||
|
'div',
|
||||||
|
{ class: 'form-field' },
|
||||||
|
h('label', { for: id, class: 'field-label' }, spec.label, spec.required ? h('span', { 'aria-hidden': 'true' }, ' *') : null),
|
||||||
|
input,
|
||||||
|
datalist,
|
||||||
|
help,
|
||||||
|
)
|
||||||
|
return { wrap, spec, input, help, datalist }
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshField({ spec, input, help, datalist }: Field): void {
|
||||||
|
if (spec.type === 'bool') {
|
||||||
|
help.textContent = spec.help ?? ' '
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const raw = this.values[spec.name] ?? ''
|
||||||
|
const err = paramError(spec, raw, this.games)
|
||||||
|
const bad = this.showErrors && err !== null
|
||||||
|
input.classList.toggle('input-bad', bad)
|
||||||
|
input.setAttribute('aria-invalid', String(bad))
|
||||||
|
help.className = bad ? 'field-help text-danger' : 'field-help'
|
||||||
|
const game = spec.type === 'app_id' ? this.games.find((g) => g.app_id === Number(raw)) : undefined
|
||||||
|
help.textContent = bad
|
||||||
|
? err
|
||||||
|
: game
|
||||||
|
? game.name
|
||||||
|
: (spec.help ?? (spec.type === 'app_id' ? 'Type an app id or pick a game from the list.' : ' '))
|
||||||
|
if (datalist && datalist.childElementCount !== this.games.length) {
|
||||||
|
datalist.replaceChildren(...this.games.map((g) => h('option', { value: g.app_id }, g.name)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
213
web/src/commands/pendingCountdown.test.ts
Normal file
213
web/src/commands/pendingCountdown.test.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { makeJob, makePending } from '../test/fixtures'
|
||||||
|
import { apiError, bodiesOf, flush, stubApi, type Handler } from '../test/harness'
|
||||||
|
import { button, must, setValue, submit } from '../test/events'
|
||||||
|
import { PendingCountdown } from './pendingCountdown'
|
||||||
|
|
||||||
|
const T0 = Date.parse('2026-10-10T10:00:00Z')
|
||||||
|
const pending = (over = {}) =>
|
||||||
|
makePending({ armed_at: '2026-10-10T10:00:00Z', ready_at: '2026-10-10T10:00:30Z', heartbeat_interval_seconds: 10, ...over })
|
||||||
|
|
||||||
|
const made: PendingCountdown[] = []
|
||||||
|
|
||||||
|
function make(over = {}) {
|
||||||
|
const cbs = { onCommitted: vi.fn(), onCancel: vi.fn(), onRearm: vi.fn() }
|
||||||
|
const countdown = new PendingCountdown(pending(over), cbs)
|
||||||
|
made.push(countdown)
|
||||||
|
document.body.append(countdown.el)
|
||||||
|
return { ...cbs, countdown, el: countdown.el }
|
||||||
|
}
|
||||||
|
const tick = (ms: number) => vi.advanceTimersByTimeAsync(ms)
|
||||||
|
const text = () => document.body.textContent
|
||||||
|
const phraseInput = () => must(document.querySelector<HTMLInputElement>('.phrase input'))
|
||||||
|
|
||||||
|
describe('PendingCountdown', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ now: T0 })
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
// The pagehide listener lives on window: release every instance.
|
||||||
|
for (const c of made.splice(0)) c.dispose()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('counts down on the server clock, one second past ready_at', async () => {
|
||||||
|
stubApi({ 'POST /api/pending/p1/heartbeat': pending() })
|
||||||
|
const { el } = make()
|
||||||
|
expect(text()).toContain('0:31')
|
||||||
|
expect(text()).toContain('until commit unlocks')
|
||||||
|
await tick(10_000)
|
||||||
|
expect(text()).toContain('0:21')
|
||||||
|
expect(el.querySelector('[role=progressbar]')).toHaveAttribute('aria-valuenow', '32')
|
||||||
|
expect(text()).toContain('Closing this dialog')
|
||||||
|
expect(button(el, 'Cancel countdown')).toHaveAttribute('autofocus')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the commit locked until a second after the ready time', async () => {
|
||||||
|
stubApi({ 'POST /api/pending/p1/heartbeat': pending() })
|
||||||
|
make()
|
||||||
|
await tick(30_000)
|
||||||
|
expect(text()).toContain('until commit unlocks')
|
||||||
|
await tick(1_000)
|
||||||
|
expect(text()).toContain('Ready to commit')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('heartbeats at half the interval and takes the server ready time', async () => {
|
||||||
|
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': () => pending({ ready_at: '2026-10-10T10:01:00Z' }) })
|
||||||
|
make()
|
||||||
|
await tick(4_999)
|
||||||
|
expect(mock).not.toHaveBeenCalled()
|
||||||
|
await tick(1)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1)
|
||||||
|
await tick(250)
|
||||||
|
expect(text()).toMatch(/Heartbeat every 5 s · last 0 s ago/)
|
||||||
|
expect(text()).toContain('0:56')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never heartbeats faster than once a second', async () => {
|
||||||
|
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending({ heartbeat_interval_seconds: 0.5 }) })
|
||||||
|
make({ heartbeat_interval_seconds: 0.5 })
|
||||||
|
await tick(999)
|
||||||
|
expect(mock).not.toHaveBeenCalled()
|
||||||
|
await tick(1)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports a failing heartbeat and recovers when it works again', async () => {
|
||||||
|
const routes: Record<string, Handler> = { 'POST /api/pending/p1/heartbeat': apiError(503, 'daemon_unreachable') }
|
||||||
|
stubApi(routes)
|
||||||
|
make()
|
||||||
|
await tick(5_000)
|
||||||
|
expect(text()).toContain('heartbeat failing, retrying')
|
||||||
|
routes['POST /api/pending/p1/heartbeat'] = pending()
|
||||||
|
await tick(5_000)
|
||||||
|
expect(text()).not.toContain('heartbeat failing')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers to arm again once the server says the countdown lapsed', async () => {
|
||||||
|
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed a beat') })
|
||||||
|
const { onRearm, el } = make()
|
||||||
|
await tick(5_000)
|
||||||
|
expect(text()).toContain('missed a beat')
|
||||||
|
expect(el.className).toBe('stack')
|
||||||
|
button(el, 'Arm again').click()
|
||||||
|
expect(onRearm).toHaveBeenCalled()
|
||||||
|
await tick(60_000)
|
||||||
|
expect(mock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops the action with a keepalive request when the page is hidden', () => {
|
||||||
|
const mock = stubApi({ 'DELETE /api/pending/p1': undefined })
|
||||||
|
make()
|
||||||
|
window.dispatchEvent(new Event('pagehide'))
|
||||||
|
expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE', keepalive: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('swallows a failed keepalive drop', async () => {
|
||||||
|
stubApi({ 'DELETE /api/pending/p1': apiError(500, 'op_failed') })
|
||||||
|
make()
|
||||||
|
window.dispatchEvent(new Event('pagehide'))
|
||||||
|
await tick(0)
|
||||||
|
expect(text()).toContain('0:31')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stops heartbeating and listening once disposed', async () => {
|
||||||
|
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined })
|
||||||
|
const { countdown } = make()
|
||||||
|
countdown.dispose()
|
||||||
|
await tick(60_000)
|
||||||
|
window.dispatchEvent(new Event('pagehide'))
|
||||||
|
expect(mock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels, whatever the server answers', async () => {
|
||||||
|
stubApi({ 'DELETE /api/pending/p1': apiError(404, 'not_found') })
|
||||||
|
const { onCancel, el } = make()
|
||||||
|
button(el, 'Cancel countdown').click()
|
||||||
|
await tick(0)
|
||||||
|
expect(onCancel).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Cancelling… while the request runs', async () => {
|
||||||
|
stubApi({ 'DELETE /api/pending/p1': () => new Promise(() => undefined) })
|
||||||
|
const { el, onCancel } = make()
|
||||||
|
button(el, 'Cancel countdown').click()
|
||||||
|
await flush()
|
||||||
|
expect(button(el, 'Cancelling…')).toBeInTheDocument()
|
||||||
|
expect(onCancel).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('once ready', () => {
|
||||||
|
const ready = async () => {
|
||||||
|
const m = make()
|
||||||
|
await tick(31_000)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
it('asks for the phrase again, and commits only when it matches', async () => {
|
||||||
|
const job = makeJob({ id: 'jc' })
|
||||||
|
const mock = stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': job })
|
||||||
|
const { onCommitted, el } = await ready()
|
||||||
|
expect(text()).toContain('Ready to commit')
|
||||||
|
expect(el.querySelector('.sr-only[aria-live]')).toHaveTextContent('Countdown finished')
|
||||||
|
const commit = button(el, 'Commit')
|
||||||
|
expect(commit).toBeDisabled()
|
||||||
|
submit(commit)
|
||||||
|
await flush()
|
||||||
|
expect(onCommitted).not.toHaveBeenCalled()
|
||||||
|
setValue(phraseInput(), 'reset gaming now')
|
||||||
|
expect(commit).toBeEnabled()
|
||||||
|
submit(commit)
|
||||||
|
await tick(0)
|
||||||
|
expect(onCommitted).toHaveBeenCalledWith(job)
|
||||||
|
expect(bodiesOf(mock, 'POST /api/pending/p1/commit')).toEqual([{ confirm_phrase: 'reset gaming now' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the same input while typing', async () => {
|
||||||
|
stubApi({ 'POST /api/pending/p1/heartbeat': pending() })
|
||||||
|
await ready()
|
||||||
|
const input = phraseInput()
|
||||||
|
setValue(input, 'res')
|
||||||
|
await tick(250)
|
||||||
|
expect(phraseInput()).toBe(input)
|
||||||
|
expect(input).toHaveValue('res')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a refused commit and stays open', async () => {
|
||||||
|
stubApi({
|
||||||
|
'POST /api/pending/p1/heartbeat': pending(),
|
||||||
|
'POST /api/pending/p1/commit': apiError(409, 'countdown_running', 'not yet'),
|
||||||
|
})
|
||||||
|
const { el, onCommitted } = await ready()
|
||||||
|
setValue(phraseInput(), 'reset gaming now')
|
||||||
|
submit(button(el, 'Commit'))
|
||||||
|
await tick(0)
|
||||||
|
expect(text()).toContain('not yet')
|
||||||
|
expect(onCommitted).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Committing… while it is in flight', async () => {
|
||||||
|
stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'POST /api/pending/p1/commit': () => new Promise(() => undefined) })
|
||||||
|
const { el } = await ready()
|
||||||
|
setValue(phraseInput(), 'reset gaming now')
|
||||||
|
submit(button(el, 'Commit'))
|
||||||
|
await flush()
|
||||||
|
expect(button(el, 'Committing…')).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can still be cancelled', async () => {
|
||||||
|
stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': undefined })
|
||||||
|
const { el, onCancel } = await ready()
|
||||||
|
button(el, 'Cancel').click()
|
||||||
|
await tick(0)
|
||||||
|
expect(onCancel).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can be cancelled in flight and keeps the phrase form', async () => {
|
||||||
|
stubApi({ 'POST /api/pending/p1/heartbeat': pending(), 'DELETE /api/pending/p1': () => new Promise(() => undefined) })
|
||||||
|
const { el } = await ready()
|
||||||
|
button(el, 'Cancel').click()
|
||||||
|
await flush()
|
||||||
|
expect(phraseInput()).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
185
web/src/commands/pendingCountdown.ts
Normal file
185
web/src/commands/pendingCountdown.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
// Armed countdown (gaming-reset), was PendingCountdown.tsx: heartbeats at
|
||||||
|
// half the server's interval so one slow request never lapses it, a
|
||||||
|
// countdown driven by the server's `ready_at` (not a local timer), then the
|
||||||
|
// phrase is retyped to commit. Closing the page drops the action with a
|
||||||
|
// keepalive DELETE.
|
||||||
|
|
||||||
|
import { api, ApiFailure } from '../api/client'
|
||||||
|
import type { Job, PendingAction } from '../api/jobContract'
|
||||||
|
import { progressBar } from '../jobs/progress'
|
||||||
|
import { Action } from '../lib/action'
|
||||||
|
import { h } from '../lib/dom'
|
||||||
|
import { errorNotice, notice } from '../ui/build'
|
||||||
|
import { clock } from '../ui/format'
|
||||||
|
import { phraseMatches } from './catalog'
|
||||||
|
import { PhraseField } from './phraseField'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daemon checks `ready_at` on its own clock; unlocking the commit a
|
||||||
|
* second late keeps a slightly fast browser clock from offering a commit the
|
||||||
|
* daemon refuses (seen in vmbox: refused 34 ms early, countdown_running).
|
||||||
|
*/
|
||||||
|
export const CLOCK_SLACK_MS = 1000
|
||||||
|
|
||||||
|
export interface CountdownCallbacks {
|
||||||
|
onCommitted: (job: Job) => void
|
||||||
|
onCancel: () => void
|
||||||
|
onRearm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PendingCountdown {
|
||||||
|
readonly el = h('div', { class: 'stack countdown' })
|
||||||
|
private pending: PendingAction
|
||||||
|
private lastBeat = Date.now()
|
||||||
|
private beatError: unknown = null
|
||||||
|
private phrase: PhraseField | null = null
|
||||||
|
private readonly commit: Action<[string], Job>
|
||||||
|
private readonly cancel: Action<[], void>
|
||||||
|
private readonly cb: CountdownCallbacks
|
||||||
|
private readonly timers: ReturnType<typeof setInterval>[] = []
|
||||||
|
private readonly drop = () => {
|
||||||
|
void api.dropPending(this.pending.id, true).catch(() => undefined)
|
||||||
|
}
|
||||||
|
private shownReady: boolean | null = null
|
||||||
|
private commitForm: { submit: HTMLButtonElement; errors: HTMLDivElement } | null = null
|
||||||
|
private readonly parts = {
|
||||||
|
num: h('span', { class: 'countdown-num' }),
|
||||||
|
caption: h('span', { class: 'muted' }),
|
||||||
|
bar: h('div', { class: 'contents' }),
|
||||||
|
beat: h('p', { class: 'field-help' }),
|
||||||
|
live: h('p', { class: 'sr-only', 'aria-live': 'polite' }),
|
||||||
|
body: h('div', { class: 'contents' }),
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(pending: PendingAction, cb: CountdownCallbacks) {
|
||||||
|
this.pending = pending
|
||||||
|
this.cb = cb
|
||||||
|
this.commit = new Action((phrase: string) => api.commit(this.pending.id, phrase), () => {
|
||||||
|
this.renderBody(true)
|
||||||
|
})
|
||||||
|
this.cancel = new Action(() => api.dropPending(this.pending.id), () => {
|
||||||
|
this.renderBody(this.shownReady === true)
|
||||||
|
})
|
||||||
|
const everyMs = Math.max(1000, (pending.heartbeat_interval_seconds * 1000) / 2)
|
||||||
|
this.timers.push(
|
||||||
|
setInterval(() => {
|
||||||
|
api.heartbeat(pending.id).then(
|
||||||
|
(p) => {
|
||||||
|
this.pending = p
|
||||||
|
this.lastBeat = Date.now()
|
||||||
|
this.beatError = null
|
||||||
|
},
|
||||||
|
(e: unknown) => {
|
||||||
|
this.beatError = e
|
||||||
|
this.tick()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}, everyMs),
|
||||||
|
setInterval(() => {
|
||||||
|
this.tick()
|
||||||
|
}, 250),
|
||||||
|
)
|
||||||
|
window.addEventListener('pagehide', this.drop)
|
||||||
|
this.el.append(
|
||||||
|
h('div', { class: 'countdown-clock', 'aria-live': 'off' }, this.parts.num, this.parts.caption),
|
||||||
|
this.parts.bar,
|
||||||
|
this.parts.beat,
|
||||||
|
this.parts.live,
|
||||||
|
this.parts.body,
|
||||||
|
)
|
||||||
|
this.tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop heartbeats and timers (the dialog closed or moved on). */
|
||||||
|
dispose(): void {
|
||||||
|
for (const t of this.timers) clearInterval(t)
|
||||||
|
window.removeEventListener('pagehide', this.drop)
|
||||||
|
}
|
||||||
|
|
||||||
|
private tick(): void {
|
||||||
|
if (this.beatError instanceof ApiFailure && this.beatError.code === 'pending_lapsed') {
|
||||||
|
this.dispose()
|
||||||
|
this.el.className = 'stack'
|
||||||
|
this.el.replaceChildren(
|
||||||
|
errorNotice(this.beatError),
|
||||||
|
h('div', { class: 'actions' }, h('button', { type: 'button', class: 'btn btn-primary', autofocus: true, onclick: this.cb.onRearm }, 'Arm again')),
|
||||||
|
)
|
||||||
|
this.el.querySelector<HTMLElement>('[autofocus]')?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const now = Date.now()
|
||||||
|
const armed = Date.parse(this.pending.armed_at)
|
||||||
|
const ready = Date.parse(this.pending.ready_at) + CLOCK_SLACK_MS
|
||||||
|
const left = Math.max(0, (ready - now) / 1000)
|
||||||
|
const isReady = left <= 0
|
||||||
|
this.parts.num.textContent = clock(left)
|
||||||
|
this.parts.caption.textContent = isReady ? 'Ready to commit' : 'until commit unlocks'
|
||||||
|
this.parts.bar.replaceChildren(progressBar(Math.min(1, (now - armed) / (ready - armed)), 'Countdown', isReady ? 'success' : 'warning'))
|
||||||
|
this.parts.beat.replaceChildren(
|
||||||
|
`Keep this window open. Heartbeat every ${Math.round(this.pending.heartbeat_interval_seconds / 2)} s · last ${Math.round((now - this.lastBeat) / 1000)} s ago`,
|
||||||
|
this.beatError !== null ? h('span', { class: 'text-danger' }, ' · heartbeat failing, retrying') : '',
|
||||||
|
)
|
||||||
|
this.parts.live.textContent = isReady ? 'Countdown finished. Retype the phrase to commit.' : ''
|
||||||
|
if (isReady !== this.shownReady) this.renderBody(isReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderBody(isReady: boolean): void {
|
||||||
|
const first = this.shownReady !== isReady
|
||||||
|
this.shownReady = isReady
|
||||||
|
if (!isReady) {
|
||||||
|
this.parts.body.replaceChildren(
|
||||||
|
notice('warning', null, 'Closing this dialog, navigating away or closing the tab cancels the countdown.'),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'actions' },
|
||||||
|
h('button', { type: 'button', class: 'btn btn-ghost', autofocus: true, onclick: () => void this.doCancel() }, this.cancel.pending ? 'Cancelling…' : 'Cancel countdown'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (first || !this.phrase) {
|
||||||
|
const submitBtn = h('button', { type: 'submit', class: 'btn btn-danger' })
|
||||||
|
const errorSlot = h('div', { class: 'contents' })
|
||||||
|
this.phrase = new PhraseField({
|
||||||
|
phrase: this.pending.phrase,
|
||||||
|
autofocus: true,
|
||||||
|
label: 'Retype the phrase to commit',
|
||||||
|
onChange: () => {
|
||||||
|
this.renderBody(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const form = h(
|
||||||
|
'form',
|
||||||
|
{ class: 'stack' },
|
||||||
|
this.phrase.el,
|
||||||
|
errorSlot,
|
||||||
|
h('div', { class: 'actions' }, submitBtn, h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => void this.doCancel() }, 'Cancel')),
|
||||||
|
)
|
||||||
|
form.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (this.phrase && phraseMatches(this.pending.phrase, this.phrase.value)) void this.doCommit(this.phrase.value)
|
||||||
|
})
|
||||||
|
this.parts.body.replaceChildren(form)
|
||||||
|
this.commitForm = { submit: submitBtn, errors: errorSlot }
|
||||||
|
this.phrase.input.focus()
|
||||||
|
}
|
||||||
|
const { submit, errors } = this.commitForm as { submit: HTMLButtonElement; errors: HTMLDivElement }
|
||||||
|
submit.disabled = !phraseMatches(this.pending.phrase, this.phrase.value) || this.commit.pending
|
||||||
|
submit.textContent = this.commit.pending ? 'Committing…' : 'Commit'
|
||||||
|
errors.replaceChildren(...(this.commit.error ? [errorNotice(this.commit.error)] : []))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doCommit(phrase: string): Promise<void> {
|
||||||
|
const out = await this.commit.run(phrase)
|
||||||
|
if (out.ok) {
|
||||||
|
this.dispose()
|
||||||
|
this.cb.onCommitted(out.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doCancel(): Promise<void> {
|
||||||
|
await this.cancel.run()
|
||||||
|
this.dispose()
|
||||||
|
this.cb.onCancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
65
web/src/commands/phraseField.test.ts
Normal file
65
web/src/commands/phraseField.test.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { PhraseField } from './phraseField'
|
||||||
|
|
||||||
|
function make(over: { value?: string; label?: string; autofocus?: boolean } = {}) {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const field = new PhraseField({ phrase: 'reset it', onChange, ...over })
|
||||||
|
document.body.append(field.el)
|
||||||
|
return { field, onChange, input: field.input, status: () => field.el.querySelector('[aria-live]') }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PhraseField', () => {
|
||||||
|
it('counts characters while the prefix is right', async () => {
|
||||||
|
const { input, status, onChange } = make()
|
||||||
|
expect(status()).toHaveTextContent('0 of 8 characters')
|
||||||
|
await userEvent.type(input, 'res')
|
||||||
|
expect(status()).toHaveTextContent('3 of 8 characters')
|
||||||
|
expect(input).toHaveAttribute('aria-invalid', 'false')
|
||||||
|
expect(input.className).toBe('input mono')
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith('res')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks a mismatch', async () => {
|
||||||
|
const { input, status } = make()
|
||||||
|
await userEvent.type(input, 'rx')
|
||||||
|
expect(status()).toHaveTextContent(/Does not match/)
|
||||||
|
expect(status()).toHaveClass('text-danger')
|
||||||
|
expect(input).toHaveClass('input-bad')
|
||||||
|
expect(input).toHaveAttribute('aria-invalid', 'true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms the full phrase, ignoring surrounding whitespace', () => {
|
||||||
|
const { input, status, field } = make({ value: ' reset it ' })
|
||||||
|
expect(status()).toHaveTextContent('Matches.')
|
||||||
|
expect(status()).toHaveClass('text-success')
|
||||||
|
expect(input).toHaveClass('input-ok')
|
||||||
|
expect(field.value).toBe(' reset it ')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('colours each target character by whether it was typed right', () => {
|
||||||
|
const { field } = make({ value: 'rx' })
|
||||||
|
const spans = [...field.el.querySelectorAll('.phrase-target span')]
|
||||||
|
expect(spans).toHaveLength(8)
|
||||||
|
expect(spans[0]).toHaveClass('ok')
|
||||||
|
expect(spans[1]).toHaveClass('bad')
|
||||||
|
expect(spans[2]).not.toHaveClass('ok')
|
||||||
|
expect(spans[2]).not.toHaveClass('bad')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks paste and drop', () => {
|
||||||
|
const { input } = make()
|
||||||
|
expect(input.dispatchEvent(new Event('paste', { cancelable: true }))).toBe(false)
|
||||||
|
expect(input.dispatchEvent(new Event('drop', { cancelable: true }))).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('labels the input, shows the phrase to screen readers, and can ask for focus', () => {
|
||||||
|
const { field, input } = make({ autofocus: true })
|
||||||
|
expect(field.el.querySelector('label')).toHaveTextContent('Type the phrase to confirm')
|
||||||
|
expect(field.el.querySelector('label')).toHaveAttribute('for', input.id)
|
||||||
|
expect(field.el.querySelector('.sr-only')).toHaveTextContent('Phrase: reset it')
|
||||||
|
expect(input).toHaveAttribute('autofocus')
|
||||||
|
expect(make({ label: 'Again' }).field.el.querySelector('label')).toHaveTextContent('Again')
|
||||||
|
expect(make().input).not.toHaveAttribute('autofocus')
|
||||||
|
})
|
||||||
|
})
|
||||||
80
web/src/commands/phraseField.ts
Normal file
80
web/src/commands/phraseField.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
// Typed-phrase friction (was PhraseField.tsx). Shows the exact sentence, then
|
||||||
|
// live per-character feedback on the typed text. Paste is blocked: retyping
|
||||||
|
// is the friction. The server re-checks; this field only helps get it right.
|
||||||
|
// The input is built once, so typing never loses focus or caret position.
|
||||||
|
|
||||||
|
import { h } from '../lib/dom'
|
||||||
|
import { uid } from '../lib/ids'
|
||||||
|
import { phraseMatches } from './catalog'
|
||||||
|
|
||||||
|
export interface PhraseFieldOptions {
|
||||||
|
phrase: string
|
||||||
|
value?: string
|
||||||
|
label?: string
|
||||||
|
autofocus?: boolean
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PhraseField {
|
||||||
|
readonly el: HTMLDivElement
|
||||||
|
readonly input: HTMLInputElement
|
||||||
|
private readonly target: HTMLParagraphElement
|
||||||
|
private readonly status: HTMLParagraphElement
|
||||||
|
private readonly phrase: string
|
||||||
|
|
||||||
|
constructor({ phrase, value = '', label = 'Type the phrase to confirm', autofocus = false, onChange }: PhraseFieldOptions) {
|
||||||
|
this.phrase = phrase
|
||||||
|
const id = uid('phrase')
|
||||||
|
this.target = h('p', { class: 'phrase-target', 'aria-hidden': 'true' })
|
||||||
|
this.status = h('p', { id: `${id}-status`, 'aria-live': 'polite' })
|
||||||
|
this.input = h('input', {
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
autocomplete: 'off',
|
||||||
|
spellcheck: 'false',
|
||||||
|
autofocus,
|
||||||
|
'aria-describedby': `${id}-status`,
|
||||||
|
})
|
||||||
|
this.input.value = value
|
||||||
|
const block = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
this.input.addEventListener('paste', block)
|
||||||
|
this.input.addEventListener('drop', block)
|
||||||
|
this.input.addEventListener('input', () => {
|
||||||
|
this.refresh()
|
||||||
|
onChange(this.input.value)
|
||||||
|
})
|
||||||
|
this.el = h(
|
||||||
|
'div',
|
||||||
|
{ class: 'phrase' },
|
||||||
|
h('label', { for: id, class: 'field-label' }, label),
|
||||||
|
this.target,
|
||||||
|
this.input,
|
||||||
|
h('p', { class: 'sr-only' }, `Phrase: ${phrase}`),
|
||||||
|
this.status,
|
||||||
|
)
|
||||||
|
this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): string {
|
||||||
|
return this.input.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
const typed = this.input.value.trim()
|
||||||
|
const ok = phraseMatches(this.phrase, this.input.value)
|
||||||
|
const prefixOk = this.phrase.startsWith(typed)
|
||||||
|
this.target.replaceChildren(
|
||||||
|
...Array.from(this.phrase).map((ch, i) => h('span', { class: i < typed.length ? (typed[i] === ch ? 'ok' : 'bad') : null }, ch)),
|
||||||
|
)
|
||||||
|
this.input.className = `input mono ${ok ? 'input-ok' : prefixOk ? '' : 'input-bad'}`.trim()
|
||||||
|
this.input.setAttribute('aria-invalid', String(!prefixOk))
|
||||||
|
this.status.className = `field-help ${ok ? 'text-success' : prefixOk ? '' : 'text-danger'}`.trim()
|
||||||
|
this.status.textContent = ok
|
||||||
|
? 'Matches.'
|
||||||
|
: prefixOk
|
||||||
|
? `${typed.length} of ${this.phrase.length} characters`
|
||||||
|
: 'Does not match — check case and spacing.'
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { createContext, useContext } from 'react'
|
|
||||||
import type { CommandName } from '../api/contract'
|
|
||||||
|
|
||||||
export interface RunnerApi {
|
|
||||||
/** Open the generic runner for a job command, optionally pre-filled. */
|
|
||||||
run: (name: CommandName, preset?: Record<string, number | string>) => void
|
|
||||||
openPalette: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RunnerContext = createContext<RunnerApi>({ run: () => {}, openPalette: () => {} })
|
|
||||||
|
|
||||||
export function useRunner(): RunnerApi {
|
|
||||||
return useContext(RunnerContext)
|
|
||||||
}
|
|
||||||
112
web/src/commands/runnerDialog.countdown.test.ts
Normal file
112
web/src/commands/runnerDialog.countdown.test.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { makeJob, makePending, makeSpec } from '../test/fixtures'
|
||||||
|
import { button, setValue, submit } from '../test/events'
|
||||||
|
import { apiError, bodiesOf, flush } from '../test/harness'
|
||||||
|
import { found, openRunner, phraseInput, text } from '../test/runnerHelpers'
|
||||||
|
|
||||||
|
const SPEC = makeSpec('gaming-reset', {
|
||||||
|
privileged: true,
|
||||||
|
friction: { phrase_template: 'reset gaming now', countdown_seconds: 300 },
|
||||||
|
})
|
||||||
|
const READY = makePending({ ready_at: '2026-10-10T09:00:00Z', armed_at: '2026-10-10T08:55:00Z' })
|
||||||
|
const FAR = makePending({ ready_at: '2099-01-01T00:00:00Z', armed_at: '2026-10-10T08:55:00Z' })
|
||||||
|
const DELETE = 'DELETE /api/pending/p1'
|
||||||
|
|
||||||
|
function open(extra: Record<string, unknown>) {
|
||||||
|
return openRunner([SPEC], 'gaming-reset', { extra })
|
||||||
|
}
|
||||||
|
const arm = async () => {
|
||||||
|
await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() })
|
||||||
|
setValue(phraseInput(), 'reset gaming now')
|
||||||
|
submit(await found('Arm countdown'))
|
||||||
|
}
|
||||||
|
const waitDeletes = (mock: ReturnType<typeof open>['mock'], n: number) =>
|
||||||
|
vi.waitFor(() => { expect(bodiesOf(mock, DELETE)).toHaveLength(n) })
|
||||||
|
|
||||||
|
describe('<runner-dialog> countdown commands', () => {
|
||||||
|
it('sends the phrase to arm, then commits with a retyped phrase and follows the job', async () => {
|
||||||
|
const { mock, el } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1', command: 'gaming-reset' }) })
|
||||||
|
await arm()
|
||||||
|
await found('Commit')
|
||||||
|
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'gaming-reset', params: {}, confirm_phrase: 'reset gaming now' }])
|
||||||
|
setValue(phraseInput(), 'reset gaming now')
|
||||||
|
submit(button(document.body, 'Commit'))
|
||||||
|
await found('Close (job keeps running)')
|
||||||
|
expect(bodiesOf(mock, 'POST /api/pending/p1/commit')).toEqual([{ confirm_phrase: 'reset gaming now' }])
|
||||||
|
expect(el.querySelector('job-progress')).toHaveAttribute('job-id', 'j1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancelling the countdown closes the dialog and the server drops it', async () => {
|
||||||
|
const { mock, el } = open({ 'POST /api/jobs': FAR, [DELETE]: undefined })
|
||||||
|
await arm()
|
||||||
|
;(await found('Cancel countdown')).click()
|
||||||
|
await waitDeletes(mock, 1)
|
||||||
|
await vi.waitFor(() => { expect(el.querySelector('dialog')).not.toHaveAttribute('open') })
|
||||||
|
expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE' }))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closing the dialog while armed drops the countdown', async () => {
|
||||||
|
const { mock, el } = open({ 'POST /api/jobs': FAR, [DELETE]: undefined })
|
||||||
|
await arm()
|
||||||
|
await found('Cancel countdown')
|
||||||
|
el.querySelector<HTMLButtonElement>('.dialog-close')?.click()
|
||||||
|
await waitDeletes(mock, 1)
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closing while armed swallows a refused drop', async () => {
|
||||||
|
const { mock, el } = open({ 'POST /api/jobs': FAR, [DELETE]: apiError(404, 'not_found') })
|
||||||
|
await arm()
|
||||||
|
await found('Cancel countdown')
|
||||||
|
el.querySelector<HTMLButtonElement>('.dialog-close')?.click()
|
||||||
|
await waitDeletes(mock, 1)
|
||||||
|
await flush()
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaving the page while armed drops it with a keepalive request, even if refused', async () => {
|
||||||
|
const { mock } = open({ 'POST /api/jobs': FAR, [DELETE]: apiError(404, 'not_found') })
|
||||||
|
await arm()
|
||||||
|
await found('Cancel countdown')
|
||||||
|
window.dispatchEvent(new Event('pagehide'))
|
||||||
|
// Both the dialog and the countdown listen: one drop each.
|
||||||
|
await waitDeletes(mock, 2)
|
||||||
|
expect(mock).toHaveBeenCalledWith('/api/pending/p1', expect.objectContaining({ method: 'DELETE', keepalive: true }))
|
||||||
|
await flush()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('after a commit, closing no longer drops anything', async () => {
|
||||||
|
const { mock, el } = open({ 'POST /api/jobs': READY, 'POST /api/pending/p1/commit': makeJob({ id: 'j1' }) })
|
||||||
|
await arm()
|
||||||
|
await found('Commit')
|
||||||
|
setValue(phraseInput(), 'reset gaming now')
|
||||||
|
submit(button(document.body, 'Commit'))
|
||||||
|
;(await found('Close (job keeps running)')).click()
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
window.dispatchEvent(new Event('pagehide'))
|
||||||
|
expect(bodiesOf(mock, DELETE)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> lapsed countdown', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arming again after a lapse returns to an empty form', async () => {
|
||||||
|
const lapsing = makePending({ ...FAR, heartbeat_interval_seconds: 0.001 })
|
||||||
|
open({ 'POST /api/jobs': lapsing, 'POST /api/pending/p1/heartbeat': apiError(410, 'pending_lapsed', 'missed') })
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
await arm()
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
await vi.advanceTimersByTimeAsync(1000)
|
||||||
|
button(document.body, 'Arm again').click()
|
||||||
|
await vi.advanceTimersByTimeAsync(50)
|
||||||
|
expect(phraseInput()).toHaveValue('')
|
||||||
|
expect(button(document.body, 'Arm countdown')).toBeDisabled()
|
||||||
|
expect(text()).not.toContain('Arm again')
|
||||||
|
})
|
||||||
|
})
|
||||||
109
web/src/commands/runnerDialog.failures.test.ts
Normal file
109
web/src/commands/runnerDialog.failures.test.ts
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { q } from '../api/queries'
|
||||||
|
import { makeSpec } from '../test/fixtures'
|
||||||
|
import { submit } from '../test/events'
|
||||||
|
import { apiError, Reply } from '../test/harness'
|
||||||
|
import { field, found, openRunner, text } from '../test/runnerHelpers'
|
||||||
|
|
||||||
|
const waitText = (s: string | RegExp) => vi.waitFor(() => {
|
||||||
|
expect(text()).toMatch(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> locked', () => {
|
||||||
|
it('is read-only when locked, and explains', async () => {
|
||||||
|
const spec = makeSpec('pick', {
|
||||||
|
locked_reason: 'Manual pick active',
|
||||||
|
friction: { phrase_template: 'x' },
|
||||||
|
params: [{ name: 'n', label: 'N', type: 'int', required: false }],
|
||||||
|
})
|
||||||
|
openRunner([spec], 'pick')
|
||||||
|
const run = await found('Run pick')
|
||||||
|
expect(text()).toContain('Manual pick active')
|
||||||
|
expect(field('N')).toBeDisabled()
|
||||||
|
expect(document.querySelector('.phrase')).toBeNull()
|
||||||
|
expect(run).toBeDisabled()
|
||||||
|
submit(run)
|
||||||
|
expect(text()).not.toContain('Starting…')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> start failures', () => {
|
||||||
|
it('shows the server error', async () => {
|
||||||
|
openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': apiError(409, 'busy', 'job 4 running') } })
|
||||||
|
submit(await found('Run scan'))
|
||||||
|
await waitText('job 4 running')
|
||||||
|
expect(text()).not.toContain('terminal instead')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('offers the sudo fallback when the daemon is down', async () => {
|
||||||
|
openRunner([makeSpec('gaming-unblock')], 'gaming-unblock', { extra: { 'POST /api/jobs': apiError(502, 'daemon_unreachable') } })
|
||||||
|
submit(await found('Run gaming-unblock'))
|
||||||
|
await waitText('Run it from a terminal instead:')
|
||||||
|
expect(text()).toContain('sudo ./run.sh gaming-unblock')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no fallback for a command without one', async () => {
|
||||||
|
openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': apiError(502, 'daemon_unreachable') } })
|
||||||
|
submit(await found('Run scan'))
|
||||||
|
await waitText('Root daemon unreachable')
|
||||||
|
expect(text()).not.toContain('terminal instead')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('copes with a garbled reply', async () => {
|
||||||
|
openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': new Reply(200, undefined, '{garbled') } })
|
||||||
|
submit(await found('Run scan'))
|
||||||
|
await waitText('Unexpected server error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Starting… while waiting', async () => {
|
||||||
|
openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': () => new Promise(() => undefined) } })
|
||||||
|
submit(await found('Run scan'))
|
||||||
|
expect(await found('Starting…')).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> lifecycle', () => {
|
||||||
|
it('close() on a dialog that is not open does nothing', () => {
|
||||||
|
const { el } = openRunner([makeSpec('scan')], 'scan')
|
||||||
|
const dialog = el as unknown as { close: () => void }
|
||||||
|
dialog.close()
|
||||||
|
expect(() => {
|
||||||
|
dialog.close()
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a catalog answer that arrives after another listener closed it', async () => {
|
||||||
|
// Query.emit walks a snapshot, so a listener removed mid-emit still runs once.
|
||||||
|
let el: HTMLElement | null = null
|
||||||
|
const off = q.commands.subscribe(() => {
|
||||||
|
;(el as unknown as { close: () => void } | null)?.close()
|
||||||
|
})
|
||||||
|
el = openRunner([makeSpec('scan')], 'scan').el
|
||||||
|
await vi.waitFor(() => { expect(q.commands.state.loading).toBe(false) })
|
||||||
|
expect(el.querySelector('form')).toBeNull()
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
off()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('closes itself and stops listening when removed', async () => {
|
||||||
|
const { el } = openRunner([makeSpec('scan')], 'scan')
|
||||||
|
await found('Run scan')
|
||||||
|
el.remove()
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Escape and the close button both close it', async () => {
|
||||||
|
const { el } = openRunner([makeSpec('scan')], 'scan')
|
||||||
|
await found('Run scan')
|
||||||
|
el.querySelector('dialog')?.dispatchEvent(new Event('cancel', { cancelable: true }))
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does nothing on pagehide when no countdown is armed', async () => {
|
||||||
|
const { mock } = openRunner([makeSpec('scan')], 'scan')
|
||||||
|
await found('Run scan')
|
||||||
|
const calls = mock.mock.calls.length
|
||||||
|
window.dispatchEvent(new Event('pagehide'))
|
||||||
|
expect(mock.mock.calls).toHaveLength(calls)
|
||||||
|
})
|
||||||
|
})
|
||||||
192
web/src/commands/runnerDialog.test.ts
Normal file
192
web/src/commands/runnerDialog.test.ts
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { q } from '../api/queries'
|
||||||
|
import { makeJob, makeSpec } from '../test/fixtures'
|
||||||
|
import { button, setValue, submit } from '../test/events'
|
||||||
|
import { apiError, bodiesOf, stubApi } from '../test/harness'
|
||||||
|
import { field, found, openRunner, phraseInput, text } from '../test/runnerHelpers'
|
||||||
|
import { runner } from '../lib/runner'
|
||||||
|
import { RunnerDialogElement } from './runnerDialog'
|
||||||
|
|
||||||
|
const waitText = (s: string | RegExp) => vi.waitFor(() => {
|
||||||
|
expect(text()).toMatch(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> catalog states', () => {
|
||||||
|
it('shows loading, then the load error', async () => {
|
||||||
|
let fail: (r: unknown) => void = () => undefined
|
||||||
|
stubApi({ 'GET /api/commands': () => new Promise((r) => { fail = r }) })
|
||||||
|
document.body.append(document.createElement('runner-dialog'))
|
||||||
|
runner.run('scan')
|
||||||
|
expect(text()).toContain('Loading commands')
|
||||||
|
fail(apiError(500, 'op_failed', 'catalog broken'))
|
||||||
|
await waitText('catalog broken')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says so when the server does not offer the command', async () => {
|
||||||
|
openRunner([makeSpec('done')], 'scan')
|
||||||
|
await waitText('Not in the server catalog')
|
||||||
|
expect(text()).toContain('does not offer "scan"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('titles the dialog with the command label', async () => {
|
||||||
|
const { el } = openRunner([makeSpec('pick-manual')], 'pick-manual')
|
||||||
|
await found('Run pick-manual')
|
||||||
|
expect(el.querySelector('dialog')).toHaveAttribute('aria-label', 'Pick manual')
|
||||||
|
expect(el.querySelector('dialog')).toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> form', () => {
|
||||||
|
it('describes the command with its tags', async () => {
|
||||||
|
openRunner(
|
||||||
|
[makeSpec('enforce', { description: 'Enforce.', privileged: true, mutating: false, cancellable: true, friction: { phrase_template: 'x', countdown_seconds: 300 } })],
|
||||||
|
'enforce',
|
||||||
|
)
|
||||||
|
await found('Arm countdown')
|
||||||
|
for (const t of ['Enforce.', 'Runs as root via the daemon', 'Read-only', 'Cancellable', '5 min countdown']) expect(text()).toContain(t)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tags a state-changing command, with no countdown for an immediate one', async () => {
|
||||||
|
openRunner([makeSpec('scan', { friction: { phrase_template: 'x' } })], 'scan')
|
||||||
|
await waitText('Changes state')
|
||||||
|
expect(text()).not.toMatch(/countdown/)
|
||||||
|
expect(text()).not.toContain('Cancellable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs the enforce preset it was opened for, with no mode field to change it', async () => {
|
||||||
|
const demo = { name: 'demo', label: 'Demo mode', type: 'int' as const, required: false, default: 0, min: 0, max: 1 }
|
||||||
|
const spec = makeSpec('enforce', { kind: 'screen', params: [demo], privileged: true, cancellable: true })
|
||||||
|
const { mock } = openRunner([spec], 'enforce', { preset: { demo: 1 }, extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } })
|
||||||
|
const run = await found('Run enforce')
|
||||||
|
expect(text()).toContain('Cancellable')
|
||||||
|
expect(text()).not.toContain('Demo mode')
|
||||||
|
expect(text()).not.toContain('Runs as root via the daemon')
|
||||||
|
submit(run)
|
||||||
|
await vi.waitFor(() => { expect(bodiesOf(mock, 'POST /api/jobs')).toHaveLength(1) })
|
||||||
|
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'enforce', params: { demo: 1 } }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('runs a parameterless command and follows its job', async () => {
|
||||||
|
const { mock, el } = openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } })
|
||||||
|
const run = await found('Run scan')
|
||||||
|
expect(run).toHaveAttribute('autofocus')
|
||||||
|
expect(run.className).toBe('btn btn-primary')
|
||||||
|
submit(run)
|
||||||
|
await found('Close (job keeps running)')
|
||||||
|
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'scan', params: {} }])
|
||||||
|
expect(el.querySelector('job-progress')).toHaveAttribute('job-id', 'j1')
|
||||||
|
expect(el.querySelector('a')).toHaveAttribute('href', '/jobs/?id=j1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('"Close" leaves the job running, and Cancel closes the form', async () => {
|
||||||
|
const { el } = openRunner([makeSpec('scan')], 'scan', { extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } })
|
||||||
|
submit(await found('Run scan'))
|
||||||
|
;(await found('Close (job keeps running)')).click()
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
runner.run('scan')
|
||||||
|
;(await found('Cancel')).click()
|
||||||
|
expect(el.querySelector('dialog')).not.toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a catalog refresh under an open form', async () => {
|
||||||
|
const { el } = openRunner([makeSpec('scan')], 'scan')
|
||||||
|
await found('Run scan')
|
||||||
|
const form = el.querySelector('form')
|
||||||
|
q.commands.invalidate()
|
||||||
|
await vi.waitFor(() => { expect(q.commands.state.loading).toBe(false) })
|
||||||
|
expect(el.querySelector('form')).toBe(form)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('re-opening replaces the previous request', async () => {
|
||||||
|
const { el } = openRunner([makeSpec('scan'), makeSpec('done')], 'scan')
|
||||||
|
await found('Run scan')
|
||||||
|
runner.run('done')
|
||||||
|
await found('Run done')
|
||||||
|
expect(el.querySelector('dialog')).toHaveAttribute('aria-label', 'Done')
|
||||||
|
expect((el as RunnerDialogElement).querySelectorAll('form')).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> parameters', () => {
|
||||||
|
const spec = makeSpec('abandon-pick', { params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }] })
|
||||||
|
|
||||||
|
it('blocks an invalid submit and shows why', async () => {
|
||||||
|
const { mock } = openRunner([spec], 'abandon-pick')
|
||||||
|
const run = await found('Run abandon-pick')
|
||||||
|
expect(run).toBeDisabled()
|
||||||
|
submit(run)
|
||||||
|
await waitText('Game is required.')
|
||||||
|
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts from a preset, names the game, and sends typed values', async () => {
|
||||||
|
const { mock } = openRunner([spec], 'abandon-pick', { preset: { app_id: 7 }, extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } })
|
||||||
|
const run = await found('Run abandon-pick')
|
||||||
|
expect(field(/Game/)).toHaveValue('7')
|
||||||
|
await vi.waitFor(() => { expect(document.querySelector('.field-help')).toHaveTextContent('Seven') })
|
||||||
|
expect(run).toBeEnabled()
|
||||||
|
submit(run)
|
||||||
|
await vi.waitFor(() => { expect(bodiesOf(mock, 'POST /api/jobs')).toHaveLength(1) })
|
||||||
|
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([{ command: 'abandon-pick', params: { app_id: 7 } }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not refresh the game names once the form is gone', async () => {
|
||||||
|
openRunner([spec], 'abandon-pick', { preset: { app_id: 7 }, extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } })
|
||||||
|
submit(await found('Run abandon-pick'))
|
||||||
|
await found('Close (job keeps running)')
|
||||||
|
q.dataset.invalidate()
|
||||||
|
await vi.waitFor(() => { expect(q.dataset.state.loading).toBe(false) })
|
||||||
|
expect(text()).toContain('Open in Jobs')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<runner-dialog> friction', () => {
|
||||||
|
const spec = makeSpec('uninstall', {
|
||||||
|
params: [{ name: 'app_id', label: 'Game', type: 'app_id', required: true }],
|
||||||
|
friction: { phrase_template: 'uninstall {game_name} and {count} others' },
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the exact phrase once its fields are known, and sends it', async () => {
|
||||||
|
const { mock } = openRunner([spec], 'uninstall', { extra: { 'POST /api/jobs': makeJob({ id: 'j1' }) } })
|
||||||
|
const run = await found('Run uninstall')
|
||||||
|
expect(text()).toMatch(/Fill in the fields above to see the exact phrase \(game_name\)/)
|
||||||
|
setValue(field(/Game/), '7')
|
||||||
|
await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() })
|
||||||
|
expect(run).toBeDisabled()
|
||||||
|
expect(run).toHaveClass('btn-danger')
|
||||||
|
setValue(phraseInput(), 'uninstall Seven and 1 others')
|
||||||
|
expect(run).toBeEnabled()
|
||||||
|
submit(run)
|
||||||
|
await vi.waitFor(() => { expect(bodiesOf(mock, 'POST /api/jobs')).toHaveLength(1) })
|
||||||
|
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([
|
||||||
|
{ command: 'uninstall', params: { app_id: 7 }, confirm_phrase: 'uninstall Seven and 1 others' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the typed phrase while unrelated fields refresh, and resets it when the sentence changes', async () => {
|
||||||
|
openRunner([spec], 'uninstall', { preset: { app_id: 7 } })
|
||||||
|
await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() })
|
||||||
|
const input = phraseInput()
|
||||||
|
setValue(input, 'uninst')
|
||||||
|
q.status.invalidate()
|
||||||
|
await vi.waitFor(() => { expect(q.status.state.loading).toBe(false) })
|
||||||
|
expect(phraseInput()).toBe(input)
|
||||||
|
expect(input).toHaveValue('uninst')
|
||||||
|
setValue(field(/Game/), '8')
|
||||||
|
await vi.waitFor(() => { expect(phraseInput()).toHaveValue('') })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('focuses the phrase straight away when there are no fields', async () => {
|
||||||
|
openRunner([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })], 'reset')
|
||||||
|
await vi.waitFor(() => { expect(phraseInput()).toHaveAttribute('autofocus') })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not submit a wrong phrase on Enter', async () => {
|
||||||
|
const { mock } = openRunner([makeSpec('reset', { friction: { phrase_template: 'reset everything' } })], 'reset')
|
||||||
|
await vi.waitFor(() => { expect(phraseInput()).toBeInTheDocument() })
|
||||||
|
setValue(phraseInput(), 'nope')
|
||||||
|
submit(phraseInput())
|
||||||
|
expect(bodiesOf(mock, 'POST /api/jobs')).toEqual([])
|
||||||
|
expect(button(document.body, 'Run reset')).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
127
web/src/commands/runnerDialog.ts
Normal file
127
web/src/commands/runnerDialog.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
// `<runner-dialog>`: the one generic job runner (was RunnerDialog.tsx +
|
||||||
|
// RunnerProvider.tsx). Opens on runner.run(); phases form → (countdown) →
|
||||||
|
// live job. An armed countdown dies with the dialog: close, Escape and
|
||||||
|
// leaving the page all drop it server-side.
|
||||||
|
|
||||||
|
import { api } from '../api/client'
|
||||||
|
import { q } from '../api/queries'
|
||||||
|
import { define, h, hx } from '../lib/dom'
|
||||||
|
import { runner, jobHref, type RunRequest } from '../lib/runner'
|
||||||
|
import { errorNotice, loading, notice } from '../ui/build'
|
||||||
|
import { Modal } from '../ui/dialog'
|
||||||
|
import { commandLabel, findCommand } from './catalog'
|
||||||
|
import type { PendingCountdown } from './pendingCountdown'
|
||||||
|
import { Runner, type NextPhase } from './runnerForm'
|
||||||
|
|
||||||
|
type Phase = { k: 'loading' } | { k: 'form'; runner: Runner } | { k: 'pending'; countdown: PendingCountdown } | { k: 'job'; id: string }
|
||||||
|
|
||||||
|
export class RunnerDialogElement extends HTMLElement {
|
||||||
|
private modal = new Modal({ title: '', wide: true, onClose: () => { this.close() } })
|
||||||
|
private request: RunRequest | null = null
|
||||||
|
private phase: Phase = { k: 'loading' }
|
||||||
|
private armed: string | null = null
|
||||||
|
private unsubs: (() => void)[] = []
|
||||||
|
private readonly onPageHide = () => {
|
||||||
|
if (this.armed) void api.dropPending(this.armed, true).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback(): void {
|
||||||
|
this.append(this.modal.el)
|
||||||
|
this.unsubs.push(runner.onRun((req) => {
|
||||||
|
this.open(req)
|
||||||
|
}))
|
||||||
|
window.addEventListener('pagehide', this.onPageHide)
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback(): void {
|
||||||
|
this.close()
|
||||||
|
for (const u of this.unsubs.splice(0)) u()
|
||||||
|
window.removeEventListener('pagehide', this.onPageHide)
|
||||||
|
}
|
||||||
|
|
||||||
|
open(req: RunRequest): void {
|
||||||
|
this.close()
|
||||||
|
this.request = req
|
||||||
|
this.modal.setTitle(commandLabel(req.name))
|
||||||
|
// The catalog is polled; the form is built once from the first answer.
|
||||||
|
this.unsubs.push(q.commands.subscribe(() => {
|
||||||
|
this.render()
|
||||||
|
}))
|
||||||
|
for (const query of [q.dataset, q.installed, q.status]) {
|
||||||
|
this.unsubs.push(query.subscribe(() => {
|
||||||
|
if (this.phase.k === 'form') this.phase.runner.refreshGames()
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
this.phase = { k: 'loading' }
|
||||||
|
this.render()
|
||||||
|
this.modal.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): void {
|
||||||
|
if (!this.request) return
|
||||||
|
if (this.armed) void api.dropPending(this.armed).catch(() => undefined)
|
||||||
|
this.armed = null
|
||||||
|
if (this.phase.k === 'pending') this.phase.countdown.dispose()
|
||||||
|
this.phase = { k: 'loading' }
|
||||||
|
this.request = null
|
||||||
|
// Keep the runner.onRun subscription (index 0); drop the per-open ones.
|
||||||
|
for (const u of this.unsubs.splice(1)) u()
|
||||||
|
this.modal.render()
|
||||||
|
this.modal.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
private render(): void {
|
||||||
|
const req = this.request
|
||||||
|
if (!req) return
|
||||||
|
if (this.phase.k !== 'loading') return
|
||||||
|
const { data: commands, error } = q.commands.state
|
||||||
|
const spec = findCommand(commands, req.name)
|
||||||
|
if (spec) {
|
||||||
|
const run = new Runner(spec, req.preset, {
|
||||||
|
onClose: () => {
|
||||||
|
this.close()
|
||||||
|
},
|
||||||
|
onArmed: (id) => {
|
||||||
|
this.armed = id
|
||||||
|
},
|
||||||
|
onPhase: (next) => {
|
||||||
|
this.setPhase(next)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.phase = { k: 'form', runner: run }
|
||||||
|
this.modal.render(run.el)
|
||||||
|
this.modal.focusFirst()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (error) this.modal.render(errorNotice(error))
|
||||||
|
else if (commands) this.modal.render(notice('danger', 'Not in the server catalog', `The server does not offer "${req.name}".`))
|
||||||
|
else this.modal.render(loading('commands'))
|
||||||
|
}
|
||||||
|
|
||||||
|
private setPhase(next: NextPhase): void {
|
||||||
|
this.phase = next
|
||||||
|
if (next.k === 'pending') {
|
||||||
|
this.modal.render(next.countdown.el)
|
||||||
|
} else if (next.k === 'job') {
|
||||||
|
this.modal.render(
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'stack' },
|
||||||
|
hx('job-progress', { 'job-id': next.id }),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'actions' },
|
||||||
|
h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => { this.close() } }, 'Close (job keeps running)'),
|
||||||
|
h('a', { href: jobHref(next.id), class: 'btn btn-ghost' }, 'Open in Jobs'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
this.modal.render(next.runner.el)
|
||||||
|
}
|
||||||
|
this.modal.focusFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
define('runner-dialog', RunnerDialogElement)
|
||||||
213
web/src/commands/runnerForm.ts
Normal file
213
web/src/commands/runnerForm.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
// The runner dialog's form phase (was the Runner half of RunnerDialog.tsx):
|
||||||
|
// params, the typed phrase, submit, then hand the next phase (countdown or
|
||||||
|
// live job) back to <runner-dialog> through RunnerCallbacks.
|
||||||
|
|
||||||
|
import { ApiFailure, startJob } from '../api/client'
|
||||||
|
import type { CommandName, CommandSpec } from '../api/contract'
|
||||||
|
import { q } from '../api/queries'
|
||||||
|
import { Action } from '../lib/action'
|
||||||
|
import { h } from '../lib/dom'
|
||||||
|
import type { Preset } from '../lib/runner'
|
||||||
|
import { commandLine, errorNotice, notice, pill } from '../ui/build'
|
||||||
|
import { fillPhrase, phraseMatches } from './catalog'
|
||||||
|
import { enforcePreset } from './enforcePreset'
|
||||||
|
import { gameLookup } from './gameLookup'
|
||||||
|
import { initialValues, paramError, toParams, type ParamValues } from './params'
|
||||||
|
import { ParamsForm } from './paramsForm'
|
||||||
|
import { PendingCountdown } from './pendingCountdown'
|
||||||
|
import { PhraseField } from './phraseField'
|
||||||
|
|
||||||
|
/** Recovery commands the UI offers when the root daemon is down. */
|
||||||
|
const FALLBACK: Partial<Record<CommandName, string>> = {
|
||||||
|
'gaming-unblock': 'sudo ./run.sh gaming-unblock',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type NextPhase = { k: 'pending'; countdown: PendingCountdown } | { k: 'job'; id: string } | { k: 'form'; runner: Runner }
|
||||||
|
|
||||||
|
export interface RunnerCallbacks {
|
||||||
|
onClose: () => void
|
||||||
|
onArmed: (pendingId: string | null) => void
|
||||||
|
onPhase: (next: NextPhase) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The form phase: params, typed phrase, submit. */
|
||||||
|
export class Runner {
|
||||||
|
readonly el: HTMLFormElement
|
||||||
|
private readonly spec: CommandSpec
|
||||||
|
private readonly fixed: Preset
|
||||||
|
private readonly preset: Preset | undefined
|
||||||
|
private readonly cb: RunnerCallbacks
|
||||||
|
private values: ParamValues
|
||||||
|
private typed = ''
|
||||||
|
private showErrors = false
|
||||||
|
private readonly form: ParamsForm
|
||||||
|
private readonly start: Action<[Parameters<typeof startJob>[0]], Awaited<ReturnType<typeof startJob>>>
|
||||||
|
private readonly phraseSlot = h('div', { class: 'contents' })
|
||||||
|
private readonly errorSlot = h('div', { class: 'contents' })
|
||||||
|
private readonly submitBtn: HTMLButtonElement
|
||||||
|
private phraseKey: string | null = null
|
||||||
|
|
||||||
|
constructor(fullSpec: CommandSpec, preset: Preset | undefined, cb: RunnerCallbacks) {
|
||||||
|
const { spec, fixed } = enforcePreset(fullSpec, preset)
|
||||||
|
this.spec = spec
|
||||||
|
this.fixed = fixed
|
||||||
|
this.preset = preset
|
||||||
|
this.cb = cb
|
||||||
|
this.values = initialValues(spec.params, preset)
|
||||||
|
const locked = spec.locked_reason !== null
|
||||||
|
this.form = new ParamsForm({
|
||||||
|
specs: spec.params,
|
||||||
|
values: this.values,
|
||||||
|
disabled: locked,
|
||||||
|
onChange: (v) => {
|
||||||
|
this.values = v
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.start = new Action(startJob, () => {
|
||||||
|
this.refresh()
|
||||||
|
})
|
||||||
|
const countdown = spec.friction?.countdown_seconds
|
||||||
|
this.submitBtn = h('button', {
|
||||||
|
type: 'submit',
|
||||||
|
class: 'btn btn-primary',
|
||||||
|
autofocus: spec.params.every((p) => p.type === 'bool') && !spec.friction,
|
||||||
|
})
|
||||||
|
this.el = h(
|
||||||
|
'form',
|
||||||
|
{ class: 'stack', novalidate: true },
|
||||||
|
h('p', { class: 'm0' }, spec.description),
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'tags' },
|
||||||
|
spec.privileged ? pill('neutral', 'Runs as root via the daemon') : null,
|
||||||
|
pill('neutral', spec.mutating ? 'Changes state' : 'Read-only'),
|
||||||
|
spec.cancellable ? pill('neutral', 'Cancellable') : null,
|
||||||
|
countdown ? pill('warning', `${Math.round(countdown / 60)} min countdown`) : null,
|
||||||
|
),
|
||||||
|
spec.locked_reason !== null ? notice('warning', 'Locked', spec.locked_reason) : null,
|
||||||
|
this.form.nodes,
|
||||||
|
this.phraseSlot,
|
||||||
|
this.errorSlot,
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
{ class: 'actions' },
|
||||||
|
this.submitBtn,
|
||||||
|
h('button', { type: 'button', class: 'btn btn-ghost', onclick: () => { cb.onClose() } }, 'Cancel'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
this.el.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
void this.submit()
|
||||||
|
})
|
||||||
|
this.refreshGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshGames(): void {
|
||||||
|
this.form.refresh(this.lookup().games, this.showErrors)
|
||||||
|
this.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private lookup() {
|
||||||
|
return gameLookup(q.dataset.state.data, q.installed.state.data, q.status.state.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private params(): Preset {
|
||||||
|
return { ...this.fixed, ...toParams(this.spec.params, this.values) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private phrase() {
|
||||||
|
return this.spec.friction ? fillPhrase(this.spec.friction.phrase_template, this.params(), this.lookup().ctx) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
private canSubmit(): boolean {
|
||||||
|
const games = this.lookup().games
|
||||||
|
const errors = this.spec.params.map((p) => paramError(p, this.values[p.name], games)).filter(Boolean)
|
||||||
|
const phrase = this.phrase()
|
||||||
|
return (
|
||||||
|
this.spec.locked_reason === null &&
|
||||||
|
errors.length === 0 &&
|
||||||
|
(!phrase || (phrase.missing.length === 0 && phraseMatches(phrase.text, this.typed)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private refresh(): void {
|
||||||
|
const phrase = this.phrase()
|
||||||
|
const locked = this.spec.locked_reason !== null
|
||||||
|
// The phrase field is rebuilt only when its sentence changes.
|
||||||
|
const key = phrase ? `${phrase.missing.join(',')}|${phrase.text}` : null
|
||||||
|
if (key !== this.phraseKey) {
|
||||||
|
this.phraseKey = key
|
||||||
|
if (phrase && phrase.missing.length > 0) {
|
||||||
|
this.phraseSlot.replaceChildren(notice('info', null, `Fill in the fields above to see the exact phrase (${phrase.missing.join(', ')}).`))
|
||||||
|
} else if (phrase && !locked) {
|
||||||
|
this.typed = ''
|
||||||
|
const field = new PhraseField({
|
||||||
|
phrase: phrase.text,
|
||||||
|
autofocus: this.spec.params.length === 0,
|
||||||
|
onChange: (v) => {
|
||||||
|
this.typed = v
|
||||||
|
this.refresh()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.phraseSlot.replaceChildren(field.el)
|
||||||
|
} else {
|
||||||
|
this.phraseSlot.replaceChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const err = this.start.error
|
||||||
|
const failure = err instanceof ApiFailure ? err : null
|
||||||
|
const fallback = FALLBACK[this.spec.name]
|
||||||
|
this.errorSlot.replaceChildren(
|
||||||
|
...(err
|
||||||
|
? [
|
||||||
|
errorNotice(
|
||||||
|
err,
|
||||||
|
failure?.code === 'daemon_unreachable' && fallback
|
||||||
|
? [h('p', { class: 'm0' }, 'Run it from a terminal instead:'), commandLine(fallback)]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
)
|
||||||
|
const countdown = this.spec.friction?.countdown_seconds
|
||||||
|
this.submitBtn.className = phrase ? 'btn btn-danger' : 'btn btn-primary'
|
||||||
|
this.submitBtn.disabled = !this.canSubmit() || this.start.pending
|
||||||
|
this.submitBtn.textContent = this.start.pending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${this.spec.name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
private async submit(): Promise<void> {
|
||||||
|
this.showErrors = true
|
||||||
|
this.form.refresh(this.lookup().games, true)
|
||||||
|
if (!this.canSubmit()) return
|
||||||
|
const phrase = this.phrase()
|
||||||
|
// Every friction command sends its phrase here; a countdown one
|
||||||
|
// (gaming-reset) needs it to arm and is asked again at commit.
|
||||||
|
const out = await this.start.run({ command: this.spec.name, params: this.params(), ...(phrase ? { confirm_phrase: this.typed } : {}) })
|
||||||
|
if (!out.ok) return
|
||||||
|
q.jobs.invalidate()
|
||||||
|
const started = out.value
|
||||||
|
if (started.kind === 'job') {
|
||||||
|
this.cb.onArmed(null)
|
||||||
|
this.cb.onPhase({ k: 'job', id: started.job.id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.cb.onArmed(started.pending.id)
|
||||||
|
const countdown: PendingCountdown = new PendingCountdown(started.pending, {
|
||||||
|
onCommitted: (job) => {
|
||||||
|
this.cb.onArmed(null)
|
||||||
|
this.cb.onPhase({ k: 'job', id: job.id })
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
this.cb.onArmed(null)
|
||||||
|
this.cb.onClose()
|
||||||
|
},
|
||||||
|
onRearm: () => {
|
||||||
|
this.cb.onArmed(null)
|
||||||
|
this.cb.onPhase({ k: 'form', runner: new Runner(this.spec, this.preset, this.cb) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
this.cb.onPhase({ k: 'pending', countdown })
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import { QueryClientProvider } from '@tanstack/react-query'
|
|
||||||
import { renderHook, waitFor } from '@testing-library/react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { queryClient } from '../api/queries'
|
|
||||||
import { makeDataset, makeGame } from '../test/factories'
|
|
||||||
import { makeInstalled, makeStatus } from '../test/fixtures'
|
|
||||||
import { stubApi } from '../test/harness'
|
|
||||||
import { useGameLookup } from './useGameLookup'
|
|
||||||
|
|
||||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
)
|
|
||||||
|
|
||||||
describe('useGameLookup', () => {
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
|
||||||
|
|
||||||
it('is empty until the sources load', () => {
|
|
||||||
stubApi({ 'GET /api/dataset': makeDataset([]), 'GET /api/installed': { games: [] }, 'GET /api/status': makeStatus({ current_app_id: null }) })
|
|
||||||
const { result } = renderHook(() => useGameLookup(), { wrapper })
|
|
||||||
expect(result.current.games).toEqual([])
|
|
||||||
expect(result.current.ctx.gameName(1)).toBeNull()
|
|
||||||
expect(result.current.ctx.installed).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('merges backlog, installed, manual picks and the assignment, sorted by name', async () => {
|
|
||||||
stubApi({
|
|
||||||
'GET /api/dataset': makeDataset([makeGame({ app_id: 1, name: 'Zeta' }), makeGame({ app_id: 2, name: 'Old name' })]),
|
|
||||||
'GET /api/installed': { games: [makeInstalled({ app_id: 2, name: 'Beta' })] },
|
|
||||||
'GET /api/status': makeStatus({
|
|
||||||
current_app_id: 4,
|
|
||||||
current_game_name: 'Delta',
|
|
||||||
manual_picks: [{ app_id: 3, name: 'Gamma', age_days: 1 }],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
const { result } = renderHook(() => useGameLookup(), { wrapper })
|
|
||||||
await waitFor(() => expect(result.current.games).toHaveLength(4))
|
|
||||||
expect(result.current.games.map((g) => g.name)).toEqual(['Beta', 'Delta', 'Gamma', 'Zeta'])
|
|
||||||
expect(result.current.ctx.gameName(2)).toBe('Beta')
|
|
||||||
expect(result.current.ctx.installed).toHaveLength(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips an unassigned status', async () => {
|
|
||||||
stubApi({
|
|
||||||
'GET /api/dataset': makeDataset([makeGame({ app_id: 1, name: 'Zeta' })]),
|
|
||||||
'GET /api/installed': { games: [] },
|
|
||||||
'GET /api/status': makeStatus({ current_app_id: null, current_game_name: null }),
|
|
||||||
})
|
|
||||||
const { result } = renderHook(() => useGameLookup(), { wrapper })
|
|
||||||
await waitFor(() => expect(result.current.games).toHaveLength(1))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { q } from '../api/queries'
|
|
||||||
import type { PhraseContext } from './catalog'
|
|
||||||
import type { GameOption } from './params'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every game the UI knows a name for (backlog snapshot, installed list,
|
|
||||||
* current assignment, manual picks), for app_id pickers and for filling
|
|
||||||
* `{game_name}` / `{count}` in phrase previews.
|
|
||||||
*/
|
|
||||||
export function useGameLookup(): { games: GameOption[]; ctx: PhraseContext } {
|
|
||||||
const dataset = useQuery(q.dataset).data
|
|
||||||
const installed = useQuery(q.installed).data
|
|
||||||
const status = useQuery(q.status).data
|
|
||||||
|
|
||||||
const byId = new Map<number, string>()
|
|
||||||
for (const g of dataset?.games ?? []) byId.set(g.app_id, g.name)
|
|
||||||
for (const g of installed?.games ?? []) byId.set(g.app_id, g.name)
|
|
||||||
for (const p of status?.manual_picks ?? []) byId.set(p.app_id, p.name)
|
|
||||||
if (status?.current_app_id && status.current_game_name) byId.set(status.current_app_id, status.current_game_name)
|
|
||||||
|
|
||||||
const games = [...byId].map(([app_id, name]) => ({ app_id, name })).sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
return {
|
|
||||||
games,
|
|
||||||
ctx: { gameName: (id) => byId.get(id) ?? null, installed: installed?.games ?? null },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
12
web/src/components/Actions.astro
Normal file
12
web/src/components/Actions.astro
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
// A titled grid of <command-card>s (was ui/Page.tsx Actions).
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
const { title = 'Actions' } = Astro.props
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class="actions-block" aria-label={title}>
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<div class="cmd-grid"><slot /></div>
|
||||||
|
</section>
|
||||||
@ -1,114 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import type { BudgetHistoryDay, BudgetLegendEntry } from '../types'
|
|
||||||
import { BudgetHistoryChart } from './BudgetHistoryChart'
|
|
||||||
|
|
||||||
const LEGEND: BudgetLegendEntry[] = [
|
|
||||||
{ key: 'app:475150', label: 'Titan Quest' },
|
|
||||||
{ key: 'proc:osu-lazer', label: 'osu!lazer' },
|
|
||||||
{ key: 'unattributed', label: 'Unattributed' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function day(over: Partial<BudgetHistoryDay> = {}): BudgetHistoryDay {
|
|
||||||
return {
|
|
||||||
day: '2026-08-28',
|
|
||||||
seconds: 14400,
|
|
||||||
segments: [{ key: 'app:475150', seconds: 14400 }],
|
|
||||||
...over,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BudgetHistoryChart', () => {
|
|
||||||
it('explains itself when nothing has been recorded', () => {
|
|
||||||
render(<BudgetHistoryChart days={[]} legend={[]} budgetSeconds={28800} />)
|
|
||||||
expect(screen.getByText(/No budget history recorded yet/)).toBeInTheDocument()
|
|
||||||
expect(document.querySelector('svg')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('draws a single day without filling the plot', () => {
|
|
||||||
render(<BudgetHistoryChart days={[day()]} legend={LEGEND} budgetSeconds={28800} />)
|
|
||||||
const bars = document.querySelectorAll('rect.seg')
|
|
||||||
expect(bars).toHaveLength(1)
|
|
||||||
// Scaled against the budget, half a budget is half the plot height.
|
|
||||||
expect(Number(bars[0].getAttribute('height'))).toBeLessThan(
|
|
||||||
Number(document.querySelector('svg')?.getAttribute('viewBox')?.split(' ')[3]) / 2,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('08-28')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stacks one band per game and colours it by legend position', () => {
|
|
||||||
render(
|
|
||||||
<BudgetHistoryChart
|
|
||||||
days={[
|
|
||||||
day({
|
|
||||||
seconds: 21600,
|
|
||||||
segments: [
|
|
||||||
{ key: 'app:475150', seconds: 14400 },
|
|
||||||
{ key: 'proc:osu-lazer', seconds: 7200 },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
legend={LEGEND}
|
|
||||||
budgetSeconds={28800}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(document.querySelectorAll('rect.seg')).toHaveLength(2)
|
|
||||||
expect(document.querySelector('rect.cat-1')).not.toBeNull()
|
|
||||||
expect(document.querySelector('rect.cat-2')).not.toBeNull()
|
|
||||||
// Bands abut: the second starts exactly where the first ends.
|
|
||||||
const [first, second] = document.querySelectorAll('rect.seg')
|
|
||||||
const firstTop = Number(first.getAttribute('y'))
|
|
||||||
const secondBottom =
|
|
||||||
Number(second.getAttribute('y')) + Number(second.getAttribute('height'))
|
|
||||||
expect(secondBottom).toBeCloseTo(firstTop, 5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders an unattributed day in its own neutral band', () => {
|
|
||||||
render(
|
|
||||||
<BudgetHistoryChart
|
|
||||||
days={[
|
|
||||||
day({
|
|
||||||
day: '2026-08-24',
|
|
||||||
seconds: 12000,
|
|
||||||
segments: [{ key: 'unattributed', seconds: 12000 }],
|
|
||||||
}),
|
|
||||||
]}
|
|
||||||
legend={LEGEND}
|
|
||||||
budgetSeconds={28800}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(document.querySelectorAll('rect.seg.unattributed')).toHaveLength(1)
|
|
||||||
// No ramp hue: an absence of information must not read as another game.
|
|
||||||
expect(document.querySelector('rect.cat-1')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('labels each band with its game in the tooltip and the legend', () => {
|
|
||||||
render(<BudgetHistoryChart days={[day()]} legend={LEGEND} budgetSeconds={28800} />)
|
|
||||||
expect(document.querySelector('rect.seg title')?.textContent).toContain('Titan Quest')
|
|
||||||
expect(document.querySelectorAll('.chart-legend li')).toHaveLength(LEGEND.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the raw key when the legend omits it', () => {
|
|
||||||
render(
|
|
||||||
<BudgetHistoryChart
|
|
||||||
days={[day({ segments: [{ key: 'app:999', seconds: 3600 }] })]}
|
|
||||||
legend={LEGEND}
|
|
||||||
budgetSeconds={28800}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(document.querySelector('rect.seg title')?.textContent).toContain('app:999')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('survives a zero budget and a day that billed nothing', () => {
|
|
||||||
render(
|
|
||||||
<BudgetHistoryChart
|
|
||||||
days={[day({ seconds: 0, segments: [] })]}
|
|
||||||
legend={[]}
|
|
||||||
budgetSeconds={0}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
// A day with no billed time draws no bands, but the axes still render.
|
|
||||||
expect(document.querySelectorAll('rect.seg')).toHaveLength(0)
|
|
||||||
expect(document.querySelector('svg')).not.toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,111 +0,0 @@
|
|||||||
import { seriesClass } from '../budgetSeries'
|
|
||||||
import { fmtDuration } from '../format'
|
|
||||||
import type { BudgetHistoryDay, BudgetLegendEntry } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
days: BudgetHistoryDay[]
|
|
||||||
legend: BudgetLegendEntry[]
|
|
||||||
budgetSeconds: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const W = 820
|
|
||||||
const H = 220
|
|
||||||
const PAD = { top: 16, right: 20, bottom: 36, left: 56 }
|
|
||||||
|
|
||||||
export function BudgetHistoryChart({ days, legend, budgetSeconds }: Props) {
|
|
||||||
if (days.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="chart">
|
|
||||||
<h2>Last 14 days</h2>
|
|
||||||
<p className="hint">
|
|
||||||
No budget history recorded yet — a bar appears for each gaming day from now on.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scaled against the budget rather than the tallest bar, so a single day
|
|
||||||
// reads as "most of the allowance" instead of a lone full-height bar that
|
|
||||||
// means nothing on its own.
|
|
||||||
const maxSeconds =
|
|
||||||
Math.max(budgetSeconds, ...days.map((d) => d.seconds)) || 1
|
|
||||||
const plotW = W - PAD.left - PAD.right
|
|
||||||
const plotH = H - PAD.top - PAD.bottom
|
|
||||||
const sy = (seconds: number) => PAD.top + plotH - (seconds / maxSeconds) * plotH
|
|
||||||
const slot = plotW / days.length
|
|
||||||
const barW = slot * 0.7
|
|
||||||
|
|
||||||
const yTicks = [0, 0.5, 1].map((f) => f * maxSeconds)
|
|
||||||
const labelFor = new Map(legend.map((e) => [e.key, e.label]))
|
|
||||||
const indexFor = new Map(legend.map((e, i) => [e.key, i]))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="chart">
|
|
||||||
<h2>Last 14 days</h2>
|
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" role="img">
|
|
||||||
{yTicks.map((s) => (
|
|
||||||
<g key={`y${s}`}>
|
|
||||||
<line x1={PAD.left} y1={sy(s)} x2={W - PAD.right} y2={sy(s)} className="grid" />
|
|
||||||
<text x={PAD.left - 8} y={sy(s) + 4} className="axis-label end">
|
|
||||||
{fmtDuration(s)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
<line
|
|
||||||
x1={PAD.left}
|
|
||||||
y1={sy(budgetSeconds)}
|
|
||||||
x2={W - PAD.right}
|
|
||||||
y2={sy(budgetSeconds)}
|
|
||||||
className="budget-line"
|
|
||||||
/>
|
|
||||||
{days.map((d, i) => {
|
|
||||||
// Bands stack upwards from the axis in legend order, so a game keeps
|
|
||||||
// the same colour and the same relative position in every bar.
|
|
||||||
let base = 0
|
|
||||||
return d.segments.map((seg) => {
|
|
||||||
const top = base + seg.seconds
|
|
||||||
const y = sy(top)
|
|
||||||
const height = sy(base) - y
|
|
||||||
base = top
|
|
||||||
return (
|
|
||||||
<rect
|
|
||||||
key={`${d.day}:${seg.key}`}
|
|
||||||
x={PAD.left + i * slot + (slot - barW) / 2}
|
|
||||||
y={y}
|
|
||||||
width={barW}
|
|
||||||
height={height}
|
|
||||||
className={seriesClass(seg.key, indexFor.get(seg.key) ?? 0)}
|
|
||||||
>
|
|
||||||
<title>
|
|
||||||
{`${d.day} · ${labelFor.get(seg.key) ?? seg.key}: ${fmtDuration(seg.seconds)}`}
|
|
||||||
</title>
|
|
||||||
</rect>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})}
|
|
||||||
{days.map((d, i) => (
|
|
||||||
<text
|
|
||||||
key={`x${d.day}`}
|
|
||||||
x={PAD.left + i * slot + slot / 2}
|
|
||||||
y={H - 12}
|
|
||||||
className="axis-label mid"
|
|
||||||
>
|
|
||||||
{d.day.slice(5)}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
<ul className="chart-legend">
|
|
||||||
{legend.map((entry, i) => (
|
|
||||||
<li key={entry.key}>
|
|
||||||
<span className={`swatch ${seriesClass(entry.key, i)}`} aria-hidden="true" />
|
|
||||||
{entry.label}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<p className="hint">
|
|
||||||
Billed gaming time per day, split by game. The dashed line is the daily budget;
|
|
||||||
today's bar can still move.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { makeBudgetSession } from '../test/factories'
|
|
||||||
import { BudgetLiveCard } from './BudgetLiveCard'
|
|
||||||
|
|
||||||
describe('BudgetLiveCard', () => {
|
|
||||||
it('reports an engaged session with its game', () => {
|
|
||||||
render(<BudgetLiveCard session={makeBudgetSession()} />)
|
|
||||||
expect(screen.getByText(/Billing — you are playing/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/Assigned game/)).toBeInTheDocument()
|
|
||||||
// Engaged, so the credited game is described in the present tense.
|
|
||||||
expect(screen.getByText(/Billing to/)).toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText('Hollow Knight')).toHaveLength(2)
|
|
||||||
expect(screen.getByText(/3 qualifying processes/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the raw verdict when unrecognised', () => {
|
|
||||||
render(<BudgetLiveCard session={makeBudgetSession({ state: 'weird' })} />)
|
|
||||||
expect(screen.getByText('weird')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('omits the game line when nothing qualifies', () => {
|
|
||||||
render(
|
|
||||||
<BudgetLiveCard
|
|
||||||
session={makeBudgetSession({
|
|
||||||
state: 'not_applicable',
|
|
||||||
qualifying_count: 0,
|
|
||||||
processes: [],
|
|
||||||
})}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/no game running/)).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/qualifying processes/)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles a qualifying session with no assigned game name', () => {
|
|
||||||
render(
|
|
||||||
<BudgetLiveCard session={makeBudgetSession({ game_name: '', billing_label: '' })} />,
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/3 qualifying processes/)).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('Hollow Knight')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('says when no verdict has been logged', () => {
|
|
||||||
render(<BudgetLiveCard session={makeBudgetSession({ available: false })} />)
|
|
||||||
expect(screen.getByText(/No verdict has been logged yet/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import { fmtAgo } from '../format'
|
|
||||||
import type { BudgetSession } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
session: BudgetSession
|
|
||||||
}
|
|
||||||
|
|
||||||
const VERDICT_LABELS: Record<string, string> = {
|
|
||||||
engaged: 'Billing — you are playing',
|
|
||||||
not_applicable: 'Not billing — no game running',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BudgetLiveCard({ session }: Props) {
|
|
||||||
if (!session.available) {
|
|
||||||
return (
|
|
||||||
<section className="budget-live">
|
|
||||||
<h2>Right now</h2>
|
|
||||||
<p className="hint">No verdict has been logged yet.</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="budget-live">
|
|
||||||
<h2>Right now</h2>
|
|
||||||
<p>
|
|
||||||
<span className={`budget-verdict budget-verdict--${session.state}`}>
|
|
||||||
{VERDICT_LABELS[session.state] ?? session.state}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
{session.qualifying_count > 0 && (
|
|
||||||
<p className="hint">
|
|
||||||
{session.game_name ? (
|
|
||||||
<>
|
|
||||||
Assigned game <strong>{session.game_name}</strong> ·{' '}
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{session.qualifying_count} qualifying processes
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{session.billing_label && (
|
|
||||||
<p className="hint">
|
|
||||||
{/* The assignment above is what the enforcer told you to play; this
|
|
||||||
is what the budget charged. A counted non-Steam game is never the
|
|
||||||
assignment, so the two legitimately differ. The key is the *last*
|
|
||||||
one credited, so it must not claim to be live once nothing
|
|
||||||
qualifies any more. */}
|
|
||||||
{session.state === 'engaged' ? 'Billing to ' : 'Last billed to '}
|
|
||||||
<strong>{session.billing_label}</strong>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="hint">
|
|
||||||
{/* The daemon logs on change or a 5-minute heartbeat, so this reading
|
|
||||||
can legitimately be minutes old. Saying so beats implying "now". */}
|
|
||||||
as of {fmtAgo(session.observed_at)}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { makeBudget } from '../test/factories'
|
|
||||||
import { BudgetPanel } from './BudgetPanel'
|
|
||||||
|
|
||||||
function stubBudget(snapshot: unknown) {
|
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => snapshot }))
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('BudgetPanel', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a loading line before the first response', () => {
|
|
||||||
stubBudget(makeBudget())
|
|
||||||
render(<BudgetPanel demo={false} />)
|
|
||||||
expect(screen.getByText(/Reading budget state/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders all four sections once loaded', async () => {
|
|
||||||
stubBudget(makeBudget())
|
|
||||||
render(<BudgetPanel demo={false} />)
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
expect(screen.getByRole('heading', { name: 'Right now' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('heading', { name: 'Last 14 days' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('heading', { name: 'Rules in effect' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('surfaces a transport failure', async () => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }),
|
|
||||||
)
|
|
||||||
render(<BudgetPanel demo={false} />)
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText(/Could not read budget state/)).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('names the permission problem and still shows the rest', async () => {
|
|
||||||
stubBudget(
|
|
||||||
makeBudget({
|
|
||||||
readable: false,
|
|
||||||
state_status: 'denied',
|
|
||||||
error: 'Cannot read the budget state file.',
|
|
||||||
today: null,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
render(<BudgetPanel demo={false} />)
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText(/Cannot read the budget state file/)).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
// The log, history and config are all still readable — the panel must not
|
|
||||||
// go blank just because one file is locked down.
|
|
||||||
expect(screen.getByRole('heading', { name: 'Right now' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('heading', { name: 'Rules in effect' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to a generic message when the backend sends none', async () => {
|
|
||||||
stubBudget(makeBudget({ readable: false, state_status: 'denied', error: null, today: null }))
|
|
||||||
render(<BudgetPanel demo={false} />)
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByText(/Budget state unavailable/)).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import { useBudgetPoll } from '../useBudgetPoll'
|
|
||||||
import { BudgetHistoryChart } from './BudgetHistoryChart'
|
|
||||||
import { BudgetLiveCard } from './BudgetLiveCard'
|
|
||||||
import { BudgetRulesCard } from './BudgetRulesCard'
|
|
||||||
import { BudgetTodayCard } from './BudgetTodayCard'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
demo: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BudgetPanel({ demo }: Props) {
|
|
||||||
const { data, error } = useBudgetPoll(demo)
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
return (
|
|
||||||
<div className="status">
|
|
||||||
<p className="error">Could not read budget state: {error}</p>
|
|
||||||
<p className="hint">
|
|
||||||
Is the backend running? Start it with <code>./run.sh serve</code>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data === null) {
|
|
||||||
return (
|
|
||||||
<div className="status">
|
|
||||||
<p className="hint">Reading budget state…</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content budget">
|
|
||||||
{/* A state file we cannot read must say so and still show everything we
|
|
||||||
can read — the log, the history and the config are all reachable. */}
|
|
||||||
{data.today === null ? (
|
|
||||||
<section className="budget-today">
|
|
||||||
<h2>Today</h2>
|
|
||||||
<p className="error">{data.error ?? 'Budget state unavailable.'}</p>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<BudgetTodayCard today={data.today} legend={data.legend} maskedCount={data.rules.masked_launchers.length} />
|
|
||||||
)}
|
|
||||||
<BudgetLiveCard session={data.session} />
|
|
||||||
<BudgetHistoryChart
|
|
||||||
days={data.history}
|
|
||||||
legend={data.legend}
|
|
||||||
budgetSeconds={data.rules.budget_seconds}
|
|
||||||
/>
|
|
||||||
<BudgetRulesCard rules={data.rules} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { makeBudgetRules } from '../test/factories'
|
|
||||||
import { BudgetRulesCard } from './BudgetRulesCard'
|
|
||||||
|
|
||||||
describe('BudgetRulesCard', () => {
|
|
||||||
it('lists the production policy', () => {
|
|
||||||
render(<BudgetRulesCard rules={makeBudgetRules()} />)
|
|
||||||
expect(screen.getByText('8h 0m')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('on')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('yes')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/demo/)).toBeNull()
|
|
||||||
expect(screen.queryByText('Masked now')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the demo mode and masked launchers when they apply', () => {
|
|
||||||
render(
|
|
||||||
<BudgetRulesCard
|
|
||||||
rules={makeBudgetRules({
|
|
||||||
enforcement: false,
|
|
||||||
counts_launchers: false,
|
|
||||||
demo: true,
|
|
||||||
masked_launchers: ['/usr/bin/steam'],
|
|
||||||
})}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/demo \(short budget/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('/usr/bin/steam')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('no')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,40 +0,0 @@
|
|||||||
import { fmtDuration } from '../format'
|
|
||||||
import type { BudgetRules } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rules: BudgetRules
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOff(value: boolean): string {
|
|
||||||
return value ? 'on' : 'off'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BudgetRulesCard({ rules }: Props) {
|
|
||||||
return (
|
|
||||||
<section className="budget-rules">
|
|
||||||
<h2>Rules in effect</h2>
|
|
||||||
<dl className="budget-rules-list">
|
|
||||||
<dt>Daily budget</dt>
|
|
||||||
<dd>{fmtDuration(rules.budget_seconds)}</dd>
|
|
||||||
<dt>Enforcement</dt>
|
|
||||||
<dd>{onOff(rules.enforcement)}</dd>
|
|
||||||
<dt>Counts launchers</dt>
|
|
||||||
<dd>{rules.counts_launchers ? 'yes' : 'no'}</dd>
|
|
||||||
<dt>Warnings at</dt>
|
|
||||||
<dd>{rules.warn_at.map((s) => fmtDuration(s)).join(', ')} remaining</dd>
|
|
||||||
{rules.demo && (
|
|
||||||
<>
|
|
||||||
<dt>Mode</dt>
|
|
||||||
<dd>demo (short budget, separate state)</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{rules.masked_launchers.length > 0 && (
|
|
||||||
<>
|
|
||||||
<dt>Masked now</dt>
|
|
||||||
<dd>{rules.masked_launchers.join(', ')}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</dl>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { makeBudgetToday } from '../test/factories'
|
|
||||||
import { BudgetTodayCard } from './BudgetTodayCard'
|
|
||||||
|
|
||||||
describe('BudgetTodayCard', () => {
|
|
||||||
it('shows remaining time, usage and the next warning', () => {
|
|
||||||
render(<BudgetTodayCard today={makeBudgetToday()} legend={[]} maskedCount={0} />)
|
|
||||||
expect(screen.getByText('7h 0m')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/1h 0m of 8h 0m used/)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/Next warning at 1h 0m remaining/)).toBeInTheDocument()
|
|
||||||
expect(document.querySelector('.budget-meter--ok')).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('turns the meter amber once a warning has fired', () => {
|
|
||||||
render(
|
|
||||||
<BudgetTodayCard
|
|
||||||
today={makeBudgetToday({ seconds_remaining: 900, warned_seconds: [3600, 1800] })}
|
|
||||||
legend={[]}
|
|
||||||
|
|
||||||
maskedCount={0}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(document.querySelector('.budget-meter--warn')).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('says so when no warnings remain', () => {
|
|
||||||
render(
|
|
||||||
<BudgetTodayCard
|
|
||||||
today={makeBudgetToday({
|
|
||||||
next_warning_seconds: null,
|
|
||||||
warned_seconds: [3600, 1800, 600, 300],
|
|
||||||
})}
|
|
||||||
legend={[]}
|
|
||||||
|
|
||||||
maskedCount={0}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/No further warnings today/)).toBeInTheDocument()
|
|
||||||
expect(document.querySelector('.budget-meter--warn')).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
const blockedToday = () =>
|
|
||||||
makeBudgetToday({
|
|
||||||
blocked: true,
|
|
||||||
blocked_at: 1,
|
|
||||||
seconds_used: 28800,
|
|
||||||
seconds_remaining: 0,
|
|
||||||
fraction_used: 1,
|
|
||||||
next_warning_seconds: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the cutoff badge when blocked', () => {
|
|
||||||
render(<BudgetTodayCard today={blockedToday()} legend={[]} maskedCount={2} />)
|
|
||||||
expect(screen.getByText(/Cutoff engaged — launchers are masked/)).toBeInTheDocument()
|
|
||||||
expect(document.querySelector('.budget-meter--blocked')).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not claim a mask that is not in place', () => {
|
|
||||||
render(<BudgetTodayCard today={blockedToday()} legend={[]} maskedCount={0} />)
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Cutoff engaged — launchers are not currently masked/),
|
|
||||||
).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import { seriesClass } from '../budgetSeries'
|
|
||||||
import { fmtDuration } from '../format'
|
|
||||||
import type { BudgetLegendEntry, BudgetToday } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
today: BudgetToday
|
|
||||||
legend: BudgetLegendEntry[]
|
|
||||||
maskedCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Which colour the meter takes: normal, into the warning band, or spent.
|
|
||||||
*
|
|
||||||
* Keyed on warnings already fired rather than on `next_warning_seconds`: that
|
|
||||||
* field is by construction always *below* the remaining time, so comparing the
|
|
||||||
* two could never turn the meter amber. "The enforcer has warned you" is also
|
|
||||||
* the thing the colour is actually trying to say.
|
|
||||||
*/
|
|
||||||
function meterState(today: BudgetToday): string {
|
|
||||||
if (today.blocked) return 'blocked'
|
|
||||||
return today.warned_seconds.length > 0 ? 'warn' : 'ok'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BudgetTodayCard({ today, legend, maskedCount }: Props) {
|
|
||||||
// Colour by the chart's legend position so a game reads the same in both
|
|
||||||
// places; today can hold games the capped 14-day legend left out, and those
|
|
||||||
// fall back to their own row order.
|
|
||||||
const indexFor = new Map(legend.map((e, i) => [e.key, i]))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="budget-today">
|
|
||||||
<h2>Today</h2>
|
|
||||||
<p className="budget-hero">
|
|
||||||
<strong>{fmtDuration(today.seconds_remaining)}</strong> left
|
|
||||||
</p>
|
|
||||||
<div className={`budget-meter budget-meter--${meterState(today)}`}>
|
|
||||||
<div
|
|
||||||
className="budget-meter-fill"
|
|
||||||
style={{ width: `${(today.fraction_used * 100).toFixed(1)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="hint">
|
|
||||||
{fmtDuration(today.seconds_used)} of {fmtDuration(today.budget_seconds)} used
|
|
||||||
{' · '}
|
|
||||||
{Math.round(today.fraction_used * 100)}%{' · '}
|
|
||||||
gaming day {today.gaming_day} (starts {today.day_starts_at})
|
|
||||||
</p>
|
|
||||||
<p className="hint">
|
|
||||||
{today.blocked ? (
|
|
||||||
<span className="budget-badge budget-badge--blocked">
|
|
||||||
{/* Read the live mount count rather than inferring it from
|
|
||||||
`blocked`: the total-block branch releases every mount while
|
|
||||||
leaving `blocked_at` set, so the two can genuinely disagree —
|
|
||||||
and a badge claiming a mask that is not there is the one thing
|
|
||||||
this panel must never do. */}
|
|
||||||
{maskedCount > 0
|
|
||||||
? 'Cutoff engaged — launchers are masked'
|
|
||||||
: 'Cutoff engaged — launchers are not currently masked'}
|
|
||||||
</span>
|
|
||||||
) : today.next_warning_seconds === null ? (
|
|
||||||
'No further warnings today.'
|
|
||||||
) : (
|
|
||||||
`Next warning at ${fmtDuration(today.next_warning_seconds)} remaining.`
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
{today.games.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h3 className="budget-subhead">Today by game</h3>
|
|
||||||
<ul className="budget-games">
|
|
||||||
{today.games.map((game, i) => (
|
|
||||||
<li key={game.key}>
|
|
||||||
<span
|
|
||||||
className={`swatch ${seriesClass(game.key, indexFor.get(game.key) ?? i)}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<span className="budget-game-name">{game.label}</span>
|
|
||||||
<span className="budget-game-time">{fmtDuration(game.seconds)}</span>
|
|
||||||
<span className="budget-game-share">
|
|
||||||
{Math.round(game.fraction * 100)}%
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
21
web/src/components/Card.astro
Normal file
21
web/src/components/Card.astro
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
// A titled box (was ui/Page.tsx Card); `aside` slot sits beside the title.
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
const { title, class: extra = '' } = Astro.props
|
||||||
|
const hasHead = Boolean(title) || Astro.slots.has('aside')
|
||||||
|
---
|
||||||
|
|
||||||
|
<section class={`box ${extra}`.trim()} aria-label={title}>
|
||||||
|
{
|
||||||
|
hasHead && (
|
||||||
|
<div class="box-head">
|
||||||
|
{title && <h2>{title}</h2>}
|
||||||
|
<slot name="aside" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<slot />
|
||||||
|
</section>
|
||||||
25
web/src/components/DevToken.astro
Normal file
25
web/src/components/DevToken.astro
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
// Dev only: the token meta the Python server injects into every built page.
|
||||||
|
// `pnpm dev:mock` sets SBE_DEV_TOKEN; `pnpm dev` reads the running server's
|
||||||
|
// token file on each request, so a restarted server's token arrives on reload.
|
||||||
|
// In `astro build` this renders nothing: the server injects the real one.
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { TOKEN_META } from '../api/contract'
|
||||||
|
|
||||||
|
function devToken(): string | null {
|
||||||
|
if (!import.meta.env.DEV) return null
|
||||||
|
if (process.env.SBE_DEV_TOKEN) return process.env.SBE_DEV_TOKEN
|
||||||
|
const port = process.env.SBE_PORT ?? '8000'
|
||||||
|
const runtime = process.env.XDG_RUNTIME_DIR ?? `/run/user/${String(process.getuid?.() ?? 0)}`
|
||||||
|
const file = join(runtime, 'steam-backlog-enforcer', `web-${port}.token`)
|
||||||
|
try {
|
||||||
|
return readFileSync(file, 'utf8').trim()
|
||||||
|
} catch {
|
||||||
|
console.warn(`[sbe] no token at ${file}: is ./run.sh serve running on port ${port}? Mutating requests will fail.`)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const token = devToken()
|
||||||
|
---
|
||||||
|
{token && <meta name={TOKEN_META} content={token} />}
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
import type { Filters } from '../types'
|
|
||||||
import { makeDataset, makeFilters } from '../test/factories'
|
|
||||||
import { FilterPanel } from './FilterPanel'
|
|
||||||
|
|
||||||
function setup(over: Partial<Filters> = {}) {
|
|
||||||
const update = vi.fn()
|
|
||||||
const onReset = vi.fn()
|
|
||||||
render(
|
|
||||||
<FilterPanel
|
|
||||||
filters={makeFilters(over)}
|
|
||||||
defaults={makeDataset().defaults}
|
|
||||||
update={update}
|
|
||||||
onReset={onReset}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
return { update, onReset, user: userEvent.setup() }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('FilterPanel', () => {
|
|
||||||
it('renders the Filters heading', () => {
|
|
||||||
setup()
|
|
||||||
expect(screen.getByRole('heading', { name: 'Filters' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches the estimate basis when a segment is clicked', async () => {
|
|
||||||
const { update, user } = setup()
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Rush' }))
|
|
||||||
expect(update).toHaveBeenCalledWith({ basis: 'rush' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches ProtonDB to min-tier mode and reveals the tier select', async () => {
|
|
||||||
const { update, user } = setup()
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Min tier' }))
|
|
||||||
expect(update).toHaveBeenCalledWith({ protonMode: 'minTier' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the tier dropdown when already in min-tier mode', () => {
|
|
||||||
setup({ protonMode: 'minTier' })
|
|
||||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches ProtonDB back to playable mode', async () => {
|
|
||||||
const { update, user } = setup({ protonMode: 'minTier' })
|
|
||||||
await user.click(screen.getByRole('button', { name: /Playable \(CLI rule\)/i }))
|
|
||||||
expect(update).toHaveBeenCalledWith({ protonMode: 'playable' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toggles the include-no-data option', async () => {
|
|
||||||
const { update, user } = setup()
|
|
||||||
await user.click(screen.getByLabelText(/Include games with no HLTB data/i))
|
|
||||||
expect(update).toHaveBeenCalledWith({ includeNoData: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reveals the fallback slider when no-data games are included', () => {
|
|
||||||
setup({ includeNoData: true })
|
|
||||||
expect(screen.getByText(/Fallback estimate/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls onReset when the reset button is clicked', async () => {
|
|
||||||
const { onReset, user } = setup()
|
|
||||||
await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i }))
|
|
||||||
expect(onReset).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates daily hours and min completions via sliders', () => {
|
|
||||||
const { update } = setup()
|
|
||||||
const sliders = screen.getAllByRole('slider')
|
|
||||||
fireEvent.change(sliders[0], { target: { value: '8' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ dailyHours: 8 })
|
|
||||||
fireEvent.change(sliders[1], { target: { value: '40' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ minCountComp: 40 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates advanced confidence and the max-hours cap', () => {
|
|
||||||
const { update } = setup()
|
|
||||||
const sliders = screen.getAllByRole('slider')
|
|
||||||
fireEvent.change(sliders[2], { target: { value: '10' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ minComp100: 10 })
|
|
||||||
fireEvent.change(sliders[3], { target: { value: '25' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ minConfidenceSum: 25 })
|
|
||||||
fireEvent.change(sliders[4], { target: { value: '50' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ maxHoursPerGame: 50 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('switches the playtime mode', async () => {
|
|
||||||
const { update, user } = setup()
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Started' }))
|
|
||||||
expect(update).toHaveBeenCalledWith({ playtimeMode: 'started' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates and clears the target date', async () => {
|
|
||||||
const { update, user } = setup({ targetDate: '2030-01-01' })
|
|
||||||
const date = document.querySelector('input[type=date]')
|
|
||||||
fireEvent.change(date as Element, { target: { value: '2031-02-03' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ targetDate: '2031-02-03' })
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Clear' }))
|
|
||||||
expect(update).toHaveBeenCalledWith({ targetDate: '' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates the min tier and treat-missing toggle in min-tier mode', async () => {
|
|
||||||
const { update, user } = setup({ protonMode: 'minTier' })
|
|
||||||
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'silver' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ protonMinTier: 'silver' })
|
|
||||||
await user.click(screen.getByLabelText(/Keep games with no ProtonDB data/i))
|
|
||||||
expect(update).toHaveBeenCalledWith({ protonTreatMissingAsPass: false })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates the fallback estimate when no-data games are included', () => {
|
|
||||||
const { update } = setup({ includeNoData: true })
|
|
||||||
const sliders = screen.getAllByRole('slider')
|
|
||||||
fireEvent.change(sliders[sliders.length - 1], { target: { value: '30' } })
|
|
||||||
expect(update).toHaveBeenCalledWith({ fallbackHours: 30 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the hours-cap value when maxHoursPerGame is set', () => {
|
|
||||||
setup({ maxHoursPerGame: 50 })
|
|
||||||
expect(screen.getByText('50 h')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,241 +0,0 @@
|
|||||||
import { SELECTABLE_TIERS } from '../protondb'
|
|
||||||
import type {
|
|
||||||
EstimateBasis,
|
|
||||||
Filters,
|
|
||||||
PlaytimeMode,
|
|
||||||
WebDefaults,
|
|
||||||
} from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
filters: Filters
|
|
||||||
defaults: WebDefaults
|
|
||||||
update: (patch: Partial<Filters>) => void
|
|
||||||
onReset: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const BASES: { id: EstimateBasis; label: string }[] = [
|
|
||||||
{ id: 'rush', label: 'Rush' },
|
|
||||||
{ id: 'leisure', label: 'Leisure' },
|
|
||||||
{ id: 'worst', label: 'Worst' },
|
|
||||||
{ id: 'pace', label: 'Pace' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const PLAYTIME: { id: PlaytimeMode; label: string }[] = [
|
|
||||||
{ id: 'all', label: 'All' },
|
|
||||||
{ id: 'started', label: 'Started' },
|
|
||||||
{ id: 'untouched', label: 'Untouched' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function FilterPanel({ filters, defaults, update, onReset }: Props) {
|
|
||||||
return (
|
|
||||||
<aside className="panel">
|
|
||||||
<div className="panel-head">
|
|
||||||
<h2>Filters</h2>
|
|
||||||
<button type="button" className="ghost" onClick={onReset}>
|
|
||||||
Reset to CLI defaults
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label>Estimate basis</label>
|
|
||||||
<div className="segmented">
|
|
||||||
{BASES.map((b) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={b.id}
|
|
||||||
className={filters.basis === b.id ? 'seg active' : 'seg'}
|
|
||||||
onClick={() => update({ basis: b.id })}
|
|
||||||
>
|
|
||||||
{b.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label>
|
|
||||||
Daily play time <span className="val">{filters.dailyHours} h/day</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0.5}
|
|
||||||
max={16}
|
|
||||||
step={0.5}
|
|
||||||
value={filters.dailyHours}
|
|
||||||
onChange={(e) => update({ dailyHours: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label>
|
|
||||||
Min HLTB completions{' '}
|
|
||||||
<span className="val">{filters.minCountComp}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={filters.minCountComp}
|
|
||||||
onChange={(e) => update({ minCountComp: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
<p className="hint">
|
|
||||||
Higher = more reliable HLTB times (CLI default {defaults.min_count_comp}).
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details className="field">
|
|
||||||
<summary>Advanced confidence</summary>
|
|
||||||
<label>
|
|
||||||
Min polled completionist times{' '}
|
|
||||||
<span className="val">{filters.minComp100}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={50}
|
|
||||||
step={1}
|
|
||||||
value={filters.minComp100}
|
|
||||||
onChange={(e) => update({ minComp100: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
<label>
|
|
||||||
Min confidence sum <span className="val">{filters.minConfidenceSum}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={150}
|
|
||||||
step={1}
|
|
||||||
value={filters.minConfidenceSum}
|
|
||||||
onChange={(e) => update({ minConfidenceSum: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label>ProtonDB compatibility</label>
|
|
||||||
<div className="segmented">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={filters.protonMode === 'playable' ? 'seg active' : 'seg'}
|
|
||||||
onClick={() => update({ protonMode: 'playable' })}
|
|
||||||
>
|
|
||||||
Playable (CLI rule)
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={filters.protonMode === 'minTier' ? 'seg active' : 'seg'}
|
|
||||||
onClick={() => update({ protonMode: 'minTier' })}
|
|
||||||
>
|
|
||||||
Min tier
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{filters.protonMode === 'minTier' && (
|
|
||||||
<div className="subfield">
|
|
||||||
<select
|
|
||||||
value={filters.protonMinTier}
|
|
||||||
onChange={(e) => update({ protonMinTier: e.target.value })}
|
|
||||||
>
|
|
||||||
{SELECTABLE_TIERS.map((t) => (
|
|
||||||
<option key={t} value={t}>
|
|
||||||
{t}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label className="check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={filters.protonTreatMissingAsPass}
|
|
||||||
onChange={(e) =>
|
|
||||||
update({ protonTreatMissingAsPass: e.target.checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
Keep games with no ProtonDB data
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label>
|
|
||||||
Max time per game{' '}
|
|
||||||
<span className="val">
|
|
||||||
{filters.maxHoursPerGame > 0 ? `${filters.maxHoursPerGame} h` : 'off'}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={200}
|
|
||||||
step={5}
|
|
||||||
value={filters.maxHoursPerGame}
|
|
||||||
onChange={(e) => update({ maxHoursPerGame: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
<p className="hint">Hide games longer than this (0 = no cap).</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label>Playtime</label>
|
|
||||||
<div className="segmented">
|
|
||||||
{PLAYTIME.map((p) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={p.id}
|
|
||||||
className={filters.playtimeMode === p.id ? 'seg active' : 'seg'}
|
|
||||||
onClick={() => update({ playtimeMode: p.id })}
|
|
||||||
>
|
|
||||||
{p.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label className="check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={filters.includeNoData}
|
|
||||||
onChange={(e) => update({ includeNoData: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Include games with no HLTB data
|
|
||||||
</label>
|
|
||||||
{filters.includeNoData && (
|
|
||||||
<div className="subfield">
|
|
||||||
<label>
|
|
||||||
Fallback estimate{' '}
|
|
||||||
<span className="val">{filters.fallbackHours} h</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={1}
|
|
||||||
max={100}
|
|
||||||
step={1}
|
|
||||||
value={filters.fallbackHours}
|
|
||||||
onChange={(e) => update({ fallbackHours: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="field">
|
|
||||||
<label>Target finish date</label>
|
|
||||||
<div className="subfield row">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={filters.targetDate}
|
|
||||||
onChange={(e) => update({ targetDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
{filters.targetDate && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="ghost"
|
|
||||||
onClick={() => update({ targetDate: '' })}
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="hint">Pick a date to see the hours/day required.</p>
|
|
||||||
</section>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,170 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
import { applyFilters } from '../estimate'
|
|
||||||
import type { GameRow } from '../estimate'
|
|
||||||
import type { WebGame } from '../types'
|
|
||||||
import { makeDataset, makeFilters, makeGame } from '../test/factories'
|
|
||||||
import { GameTable } from './GameTable'
|
|
||||||
|
|
||||||
function rowsFor(games: WebGame[]): GameRow[] {
|
|
||||||
return applyFilters(makeDataset(games), makeFilters()).rows.filter(
|
|
||||||
(r) => r.passesFilters,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('GameTable', () => {
|
|
||||||
it('renders the row count', () => {
|
|
||||||
const rows = rowsFor([makeGame({ app_id: 1 }), makeGame({ app_id: 2 })])
|
|
||||||
render(
|
|
||||||
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/Games \(2\)/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters rows by the search prop', () => {
|
|
||||||
const rows = rowsFor([
|
|
||||||
makeGame({ app_id: 1, name: 'Alpha' }),
|
|
||||||
makeGame({ app_id: 2, name: 'Beta' }),
|
|
||||||
])
|
|
||||||
render(
|
|
||||||
<GameTable
|
|
||||||
rows={rows}
|
|
||||||
search="alpha"
|
|
||||||
onSearch={vi.fn()}
|
|
||||||
onToggleExclude={vi.fn()}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText('Beta')).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('calls onSearch as the user types', async () => {
|
|
||||||
const onSearch = vi.fn()
|
|
||||||
const rows = rowsFor([makeGame({ app_id: 1, name: 'Alpha' })])
|
|
||||||
render(
|
|
||||||
<GameTable rows={rows} search="" onSearch={onSearch} onToggleExclude={vi.fn()} />,
|
|
||||||
)
|
|
||||||
await userEvent.setup().type(screen.getByPlaceholderText(/Search games/i), 'x')
|
|
||||||
expect(onSearch).toHaveBeenCalledWith('x')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('toggles sort direction when a header is clicked', async () => {
|
|
||||||
const rows = rowsFor([makeGame({ app_id: 1, name: 'Alpha' })])
|
|
||||||
render(
|
|
||||||
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
|
||||||
const header = screen.getByRole('columnheader', { name: /Game/ })
|
|
||||||
await user.click(header)
|
|
||||||
expect(header.textContent).toContain('▲')
|
|
||||||
await user.click(header)
|
|
||||||
expect(header.textContent).toContain('▼')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('invokes onToggleExclude when a keep checkbox is clicked', async () => {
|
|
||||||
const onToggleExclude = vi.fn()
|
|
||||||
const rows = rowsFor([makeGame({ app_id: 42, name: 'Alpha' })])
|
|
||||||
render(
|
|
||||||
<GameTable
|
|
||||||
rows={rows}
|
|
||||||
search=""
|
|
||||||
onSearch={vi.fn()}
|
|
||||||
onToggleExclude={onToggleExclude}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
await userEvent.setup().click(screen.getByRole('checkbox'))
|
|
||||||
expect(onToggleExclude).toHaveBeenCalledWith(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('links to a direct HLTB page when the id is known, else search', () => {
|
|
||||||
const rows = rowsFor([
|
|
||||||
makeGame({ app_id: 1, name: 'Alpha', hltb_game_id: 555 }),
|
|
||||||
makeGame({ app_id: 2, name: 'Beta', hltb_game_id: 0 }),
|
|
||||||
])
|
|
||||||
render(
|
|
||||||
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
|
|
||||||
)
|
|
||||||
expect(screen.getByRole('link', { name: 'Alpha' })).toHaveAttribute(
|
|
||||||
'href',
|
|
||||||
'https://howlongtobeat.com/game/555',
|
|
||||||
)
|
|
||||||
expect(screen.getByRole('link', { name: 'Beta' })).toHaveAttribute(
|
|
||||||
'href',
|
|
||||||
'https://howlongtobeat.com/?q=Beta',
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sorts by every column without error', async () => {
|
|
||||||
const rows = rowsFor([
|
|
||||||
makeGame({ app_id: 1, name: 'Alpha' }),
|
|
||||||
makeGame({ app_id: 2, name: 'Beta' }),
|
|
||||||
])
|
|
||||||
render(
|
|
||||||
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
|
|
||||||
)
|
|
||||||
const user = userEvent.setup()
|
|
||||||
for (const name of [
|
|
||||||
'%',
|
|
||||||
'Played',
|
|
||||||
'Rush',
|
|
||||||
'Leisure',
|
|
||||||
'Worst',
|
|
||||||
'HLTB n',
|
|
||||||
'ProtonDB',
|
|
||||||
'Game',
|
|
||||||
]) {
|
|
||||||
await user.click(screen.getByRole('columnheader', { name: new RegExp(name) }))
|
|
||||||
}
|
|
||||||
expect(screen.getByText('Alpha')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows a no-data badge for games without HLTB times', () => {
|
|
||||||
const rows = applyFilters(
|
|
||||||
makeDataset([
|
|
||||||
makeGame({
|
|
||||||
app_id: 1,
|
|
||||||
name: 'Alpha',
|
|
||||||
rush_hours: -1,
|
|
||||||
leisure_hours: -1,
|
|
||||||
worst_hours: -1,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
makeFilters({ includeNoData: true }),
|
|
||||||
).rows.filter((r) => r.passesFilters)
|
|
||||||
render(
|
|
||||||
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
|
|
||||||
)
|
|
||||||
expect(screen.getByText('no data')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('triggers the va > vb comparator branch when rows are reverse-sorted', async () => {
|
|
||||||
// Gamma > Alpha alphabetically; default sort is leisure, so clicking Game header
|
|
||||||
// causes the comparator to see va='gamma' > vb='alpha', covering cmp=1.
|
|
||||||
const rows = rowsFor([
|
|
||||||
makeGame({ app_id: 1, name: 'Gamma' }),
|
|
||||||
makeGame({ app_id: 2, name: 'Alpha' }),
|
|
||||||
])
|
|
||||||
render(
|
|
||||||
<GameTable rows={rows} search="" onSearch={vi.fn()} onToggleExclude={vi.fn()} />,
|
|
||||||
)
|
|
||||||
await userEvent.setup().click(screen.getByRole('columnheader', { name: /Game/ }))
|
|
||||||
// After ascending name sort Alpha comes first.
|
|
||||||
expect(screen.getAllByRole('row')[1].textContent).toContain('Alpha')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('caps the table and notes the overflow', () => {
|
|
||||||
const games = Array.from({ length: 301 }, (_, i) =>
|
|
||||||
makeGame({ app_id: i + 1, name: `Game ${i}` }),
|
|
||||||
)
|
|
||||||
render(
|
|
||||||
<GameTable
|
|
||||||
rows={rowsFor(games)}
|
|
||||||
search=""
|
|
||||||
onSearch={vi.fn()}
|
|
||||||
onToggleExclude={vi.fn()}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/Showing first 300 of 301/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import type { GameRow } from '../estimate'
|
|
||||||
import { fmtHoursPrecise, fmtPlaytime } from '../format'
|
|
||||||
import { tierLabel } from '../protondb'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
rows: GameRow[]
|
|
||||||
search: string
|
|
||||||
onSearch: (value: string) => void
|
|
||||||
onToggleExclude: (appId: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
type SortKey =
|
|
||||||
| 'name'
|
|
||||||
| 'completion'
|
|
||||||
| 'playtime'
|
|
||||||
| 'rush'
|
|
||||||
| 'leisure'
|
|
||||||
| 'worst'
|
|
||||||
| 'count_comp'
|
|
||||||
| 'proton'
|
|
||||||
|
|
||||||
const DISPLAY_CAP = 300
|
|
||||||
|
|
||||||
const COLUMNS: { key: SortKey; label: string; numeric: boolean }[] = [
|
|
||||||
{ key: 'name', label: 'Game', numeric: false },
|
|
||||||
{ key: 'completion', label: '%', numeric: true },
|
|
||||||
{ key: 'playtime', label: 'Played', numeric: true },
|
|
||||||
{ key: 'rush', label: 'Rush', numeric: true },
|
|
||||||
{ key: 'leisure', label: 'Leisure', numeric: true },
|
|
||||||
{ key: 'worst', label: 'Worst', numeric: true },
|
|
||||||
{ key: 'count_comp', label: 'HLTB n', numeric: true },
|
|
||||||
{ key: 'proton', label: 'ProtonDB', numeric: true },
|
|
||||||
]
|
|
||||||
|
|
||||||
function sortValue(row: GameRow, key: SortKey): number | string {
|
|
||||||
const g = row.game
|
|
||||||
switch (key) {
|
|
||||||
case 'name':
|
|
||||||
return g.name.toLowerCase()
|
|
||||||
case 'completion':
|
|
||||||
return g.completion_pct
|
|
||||||
case 'playtime':
|
|
||||||
return g.playtime_minutes
|
|
||||||
case 'rush':
|
|
||||||
return row.rush
|
|
||||||
case 'leisure':
|
|
||||||
return row.leisure
|
|
||||||
case 'worst':
|
|
||||||
return row.worst
|
|
||||||
case 'count_comp':
|
|
||||||
return g.count_comp
|
|
||||||
case 'proton':
|
|
||||||
return g.protondb_score
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hltbUrl(game: GameRow['game']): string {
|
|
||||||
if (game.hltb_game_id > 0) {
|
|
||||||
return `https://howlongtobeat.com/game/${game.hltb_game_id}`
|
|
||||||
}
|
|
||||||
return `https://howlongtobeat.com/?q=${encodeURIComponent(game.name)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GameTable({ rows, search, onSearch, onToggleExclude }: Props) {
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('leisure')
|
|
||||||
const [asc, setAsc] = useState(true)
|
|
||||||
|
|
||||||
const visible = useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
const filtered = q
|
|
||||||
? rows.filter((r) => r.game.name.toLowerCase().includes(q))
|
|
||||||
: rows
|
|
||||||
const sorted = [...filtered].sort((a, b) => {
|
|
||||||
const va = sortValue(a, sortKey)
|
|
||||||
const vb = sortValue(b, sortKey)
|
|
||||||
const cmp = va < vb ? -1 : va > vb ? 1 : 0
|
|
||||||
return asc ? cmp : -cmp
|
|
||||||
})
|
|
||||||
return sorted
|
|
||||||
}, [rows, search, sortKey, asc])
|
|
||||||
|
|
||||||
const onHeader = (key: SortKey) => {
|
|
||||||
if (key === sortKey) setAsc(!asc)
|
|
||||||
else {
|
|
||||||
setSortKey(key)
|
|
||||||
setAsc(key === 'name')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shown = visible.slice(0, DISPLAY_CAP)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="table-wrap">
|
|
||||||
<div className="table-head">
|
|
||||||
<h2>Games ({visible.length})</h2>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
placeholder="Search games…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="table-scroll">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{COLUMNS.map((c) => (
|
|
||||||
<th
|
|
||||||
key={c.key}
|
|
||||||
className={c.numeric ? 'num clickable' : 'clickable'}
|
|
||||||
onClick={() => onHeader(c.key)}
|
|
||||||
>
|
|
||||||
{c.label}
|
|
||||||
{sortKey === c.key ? (asc ? ' ▲' : ' ▼') : ''}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th>Keep</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{shown.map((r) => (
|
|
||||||
<tr key={r.game.app_id} className={r.excluded ? 'excluded' : ''}>
|
|
||||||
<td className="name">
|
|
||||||
<a href={hltbUrl(r.game)} target="_blank" rel="noreferrer">
|
|
||||||
{r.game.name}
|
|
||||||
</a>
|
|
||||||
{r.noData && <span className="badge">no data</span>}
|
|
||||||
</td>
|
|
||||||
<td className="num">{r.game.completion_pct.toFixed(0)}</td>
|
|
||||||
<td className="num">{fmtPlaytime(r.game.playtime_minutes)}</td>
|
|
||||||
<td className="num">{fmtHoursPrecise(r.rush)}</td>
|
|
||||||
<td className="num">{fmtHoursPrecise(r.leisure)}</td>
|
|
||||||
<td className="num">{fmtHoursPrecise(r.worst)}</td>
|
|
||||||
<td className="num">{r.game.count_comp}</td>
|
|
||||||
<td className="num proton">
|
|
||||||
{tierLabel(r.game.protondb_tier, r.game.protondb_trending_tier)}
|
|
||||||
</td>
|
|
||||||
<td className="num">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={!r.excluded}
|
|
||||||
onChange={() => onToggleExclude(r.game.app_id)}
|
|
||||||
aria-label={r.excluded ? 'Re-include' : 'Exclude'}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{visible.length > DISPLAY_CAP && (
|
|
||||||
<p className="hint">
|
|
||||||
Showing first {DISPLAY_CAP} of {visible.length}. Use search to narrow.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
19
web/src/components/PageHead.astro
Normal file
19
web/src/components/PageHead.astro
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
// Page title, optional subtitle (text prop or `sub` slot) and action buttons
|
||||||
|
// (default slot). Was ui/Page.tsx PageHead.
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
sub?: string
|
||||||
|
}
|
||||||
|
const { title, sub } = Astro.props
|
||||||
|
const hasActions = Astro.slots.has('default')
|
||||||
|
---
|
||||||
|
|
||||||
|
<header class="page-head">
|
||||||
|
<div>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
{sub && <p class="sub">{sub}</p>}
|
||||||
|
{Astro.slots.has('sub') && <p class="sub"><slot name="sub" /></p>}
|
||||||
|
</div>
|
||||||
|
{hasActions && <div class="page-actions"><slot /></div>}
|
||||||
|
</header>
|
||||||
@ -1,83 +0,0 @@
|
|||||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { makeDataset, makeGame, makeState } from '../test/factories'
|
|
||||||
import { PlannerView } from './PlannerView'
|
|
||||||
|
|
||||||
function stubFetch(impl: ReturnType<typeof vi.fn>) {
|
|
||||||
vi.stubGlobal('fetch', impl)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('PlannerView', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.restoreAllMocks()
|
|
||||||
})
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the planner after data loads', async () => {
|
|
||||||
const ds = makeDataset([makeGame({ app_id: 1, name: 'Alpha' })])
|
|
||||||
stubFetch(vi.fn().mockResolvedValue({ ok: true, json: async () => ds }))
|
|
||||||
render(<PlannerView />)
|
|
||||||
expect(screen.getByText(/Loading your backlog/i)).toBeInTheDocument()
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByRole('heading', { name: 'Backlog Completion Planner' })).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
expect(screen.getByText(/CLI default qualifies/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows an error when the API call fails', async () => {
|
|
||||||
stubFetch(vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }))
|
|
||||||
render(<PlannerView />)
|
|
||||||
await waitFor(() => expect(screen.getByText(/Could not load data/i)).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles a non-Error rejection', async () => {
|
|
||||||
stubFetch(vi.fn().mockRejectedValue('network down'))
|
|
||||||
render(<PlannerView />)
|
|
||||||
await waitFor(() => expect(screen.getByText(/network down/i)).toBeInTheDocument())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('recomputes scope when basis changes and a game is excluded', async () => {
|
|
||||||
const ds = makeDataset(
|
|
||||||
[makeGame({ app_id: 1, name: 'Alpha' }), makeGame({ app_id: 2, name: 'Beta' })],
|
|
||||||
{
|
|
||||||
state: makeState({
|
|
||||||
current_game_name: 'Hollow Knight',
|
|
||||||
enforcement_started_at: '2026-03-04T00:00:00+00:00',
|
|
||||||
pace_games_per_day: 0.9,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
stubFetch(vi.fn().mockResolvedValue({ ok: true, json: async () => ds }))
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<PlannerView />)
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(screen.getByRole('heading', { name: 'Backlog Completion Planner' })).toBeInTheDocument(),
|
|
||||||
)
|
|
||||||
// The current game appears in the header (covers the conditional branch).
|
|
||||||
expect(screen.getByText(/Hollow Knight/)).toBeInTheDocument()
|
|
||||||
expect(document.querySelector('.big')?.textContent).toBe('2')
|
|
||||||
|
|
||||||
// Switching basis promotes the Rush card to active.
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Rush' }))
|
|
||||||
expect(document.querySelector('.card.active .card-title')?.textContent).toBe('Rush')
|
|
||||||
|
|
||||||
// Excluding a game drops the in-scope count.
|
|
||||||
await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0])
|
|
||||||
expect(document.querySelector('.big')?.textContent).toBe('1')
|
|
||||||
|
|
||||||
// Re-including it restores the count (covers the toggle-off branch).
|
|
||||||
await user.click(within(screen.getByRole('table')).getAllByRole('checkbox')[0])
|
|
||||||
expect(document.querySelector('.big')?.textContent).toBe('2')
|
|
||||||
|
|
||||||
// Searching narrows the table (covers the search handler).
|
|
||||||
await user.type(screen.getByPlaceholderText(/Search games/i), 'Alpha')
|
|
||||||
expect(within(screen.getByRole('table')).queryByText('Beta')).toBeNull()
|
|
||||||
|
|
||||||
// Reset restores the full scope (covers the reset handler).
|
|
||||||
await user.click(screen.getByRole('button', { name: /Reset to CLI defaults/i }))
|
|
||||||
expect(document.querySelector('.big')?.textContent).toBe('2')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { fetchDataset } from '../api'
|
|
||||||
import { applyFilters } from '../estimate'
|
|
||||||
import type { Filters, WebDataset, WebDefaults } from '../types'
|
|
||||||
import { FilterPanel } from './FilterPanel'
|
|
||||||
import { GameTable } from './GameTable'
|
|
||||||
import { SummaryCards } from './SummaryCards'
|
|
||||||
import { TimelineChart } from './TimelineChart'
|
|
||||||
|
|
||||||
function defaultFilters(d: WebDefaults): Filters {
|
|
||||||
return {
|
|
||||||
minCountComp: d.min_count_comp,
|
|
||||||
minComp100: d.min_comp_100_polls,
|
|
||||||
minConfidenceSum: d.min_confidence_sum,
|
|
||||||
protonMode: 'playable',
|
|
||||||
protonMinTier: d.min_playable_tier,
|
|
||||||
protonTreatMissingAsPass: true,
|
|
||||||
dailyHours: 4,
|
|
||||||
basis: 'leisure',
|
|
||||||
maxHoursPerGame: 0,
|
|
||||||
playtimeMode: 'all',
|
|
||||||
includeNoData: false,
|
|
||||||
fallbackHours: 20,
|
|
||||||
excluded: new Set<number>(),
|
|
||||||
search: '',
|
|
||||||
targetDate: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The backlog-completion planner: the app's original single screen. */
|
|
||||||
export function PlannerView() {
|
|
||||||
const [dataset, setDataset] = useState<WebDataset | null>(null)
|
|
||||||
const [filters, setFilters] = useState<Filters | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDataset()
|
|
||||||
.then((d) => {
|
|
||||||
setDataset(d)
|
|
||||||
setFilters(defaultFilters(d.defaults))
|
|
||||||
})
|
|
||||||
.catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const result = useMemo(
|
|
||||||
() => (dataset && filters ? applyFilters(dataset, filters) : null),
|
|
||||||
[dataset, filters],
|
|
||||||
)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="status">
|
|
||||||
<p className="error">Could not load data: {error}</p>
|
|
||||||
<p className="hint">
|
|
||||||
Is the backend running? Start it with <code>./run.sh serve</code>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!dataset || !filters || !result) {
|
|
||||||
return (
|
|
||||||
<div className="status">
|
|
||||||
<p className="hint">Loading your backlog…</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = (patch: Partial<Filters>) => setFilters({ ...filters, ...patch })
|
|
||||||
|
|
||||||
const toggleExclude = (appId: number) => {
|
|
||||||
const next = new Set(filters.excluded)
|
|
||||||
if (next.has(appId)) next.delete(appId)
|
|
||||||
else next.add(appId)
|
|
||||||
setFilters({ ...filters, excluded: next })
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableRows = result.rows.filter((r) => r.passesFilters)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<header className="app-head">
|
|
||||||
<div>
|
|
||||||
<h1>Backlog Completion Planner</h1>
|
|
||||||
<p className="sub">
|
|
||||||
{dataset.state.current_game_name && (
|
|
||||||
<>
|
|
||||||
Currently playing <strong>{dataset.state.current_game_name}</strong>{' '}
|
|
||||||
·{' '}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{dataset.state.games_done_since_start} games finished since{' '}
|
|
||||||
{dataset.state.enforcement_started_at.slice(0, 10) || '—'} ·{' '}
|
|
||||||
{dataset.games.length} candidates
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="layout">
|
|
||||||
<FilterPanel
|
|
||||||
filters={filters}
|
|
||||||
defaults={dataset.defaults}
|
|
||||||
update={update}
|
|
||||||
onReset={() => setFilters(defaultFilters(dataset.defaults))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="content">
|
|
||||||
<SummaryCards
|
|
||||||
result={result}
|
|
||||||
filters={filters}
|
|
||||||
state={dataset.state}
|
|
||||||
presets={dataset.defaults.hours_per_day_presets}
|
|
||||||
defaultQualifying={dataset.default_summary.qualifying}
|
|
||||||
paceVsHltb={dataset.pace_vs_hltb}
|
|
||||||
/>
|
|
||||||
<TimelineChart result={result} filters={filters} state={dataset.state} />
|
|
||||||
<GameTable
|
|
||||||
rows={tableRows}
|
|
||||||
search={filters.search}
|
|
||||||
onSearch={(s) => update({ search: s })}
|
|
||||||
onToggleExclude={toggleExclude}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { applyFilters } from '../estimate'
|
|
||||||
import type { Filters } from '../types'
|
|
||||||
import { makeDataset, makeFilters, makeGame, makePaceVsHltb, makeState } from '../test/factories'
|
|
||||||
import { SummaryCards } from './SummaryCards'
|
|
||||||
|
|
||||||
function renderCards(filtersOver: Partial<Filters> = {}, statePace = 0, paceVsHltb = null) {
|
|
||||||
const filters = makeFilters(filtersOver)
|
|
||||||
const dataset = makeDataset([makeGame({ app_id: 1 })], { pace_vs_hltb: paceVsHltb })
|
|
||||||
const result = applyFilters(dataset, filters)
|
|
||||||
render(
|
|
||||||
<SummaryCards
|
|
||||||
result={result}
|
|
||||||
filters={filters}
|
|
||||||
state={makeState({ pace_games_per_day: statePace })}
|
|
||||||
presets={[2, 4, 6, 8]}
|
|
||||||
defaultQualifying={result.remainingGames}
|
|
||||||
paceVsHltb={paceVsHltb}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('SummaryCards', () => {
|
|
||||||
it('shows the in-scope count and a CLI-parity match badge', () => {
|
|
||||||
renderCards()
|
|
||||||
expect(screen.getByText(/games in scope/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/match/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('notes a missing start date in the pace card', () => {
|
|
||||||
renderCards({}, 0)
|
|
||||||
expect(screen.getByText(/No start date set/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the target-date banner with required hours/day', () => {
|
|
||||||
renderCards({ targetDate: '2099-01-01', basis: 'leisure' })
|
|
||||||
const banner = document.querySelector('.target-banner')
|
|
||||||
expect(banner?.textContent).toMatch(/you need/i)
|
|
||||||
expect(banner?.textContent).toMatch(/h\/day/i)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders the target banner in games/day for the pace basis', () => {
|
|
||||||
renderCards({ targetDate: '2099-01-01', basis: 'pace' }, 0.9)
|
|
||||||
const banner = document.querySelector('.target-banner')
|
|
||||||
expect(banner?.textContent).toMatch(/games\/day/i)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('PlayerSpeedInsight', () => {
|
|
||||||
it('shows empty state message when paceVsHltb is null', () => {
|
|
||||||
renderCards({}, 0, null)
|
|
||||||
expect(screen.getByText(/Your Play Style vs HLTB/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/No calibration data yet/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows calibration stats when paceVsHltb is provided', () => {
|
|
||||||
renderCards({}, 0, makePaceVsHltb())
|
|
||||||
expect(screen.getByText(/Calibration games/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/vs Rush speed/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Play style')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText(/Between rush and leisure/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows estimated total when calibration data is present', () => {
|
|
||||||
// rushTotal = 10, leisureTotal = 20, t = 0.05 → 10 + 0.05 * 10 = 10.5 h
|
|
||||||
renderCards({}, 0, makePaceVsHltb({ interpolation_t: 0.05 }))
|
|
||||||
expect(screen.getByText(/Estimated total at your pace/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides leisure ratio when ratio_vs_leisure is -1', () => {
|
|
||||||
renderCards({}, 0, makePaceVsHltb({ ratio_vs_leisure: -1, interpolation_t: -1 }))
|
|
||||||
expect(screen.queryByText(/vs Leisure speed/i)).not.toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks the rush ratio as fast when at or below 1', () => {
|
|
||||||
renderCards({}, 0, makePaceVsHltb({ ratio_vs_rush: 0.9 }))
|
|
||||||
expect(screen.getByText('0.90×')).toHaveClass('player-insight-fast')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the raw player_style key when it has no display label', () => {
|
|
||||||
renderCards({}, 0, makePaceVsHltb({ player_style: 'totally_unknown' }))
|
|
||||||
expect(screen.getByText('totally_unknown')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows N/A presets when the estimated pace total is not positive', () => {
|
|
||||||
renderCards({ excluded: new Set([1]) }, 0, makePaceVsHltb())
|
|
||||||
expect(screen.getByText(/Estimated total at your pace/i)).toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText('N/A').length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,201 +0,0 @@
|
|||||||
import { basisTotal, etaDays, paceDays, playerEstimatedTotal } from '../estimate'
|
|
||||||
import type { EstimateResult } from '../estimate'
|
|
||||||
import { fmtEta, fmtHours } from '../format'
|
|
||||||
import type { EstimateBasis, Filters, PaceVsHLTB, WebStateInfo } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: EstimateResult
|
|
||||||
filters: Filters
|
|
||||||
state: WebStateInfo
|
|
||||||
presets: number[]
|
|
||||||
defaultQualifying: number
|
|
||||||
paceVsHltb: PaceVsHLTB | null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CardData {
|
|
||||||
basis: EstimateBasis
|
|
||||||
title: string
|
|
||||||
blurb: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const CARDS: CardData[] = [
|
|
||||||
{ basis: 'rush', title: 'Rush', blurb: 'Typical fast completionist' },
|
|
||||||
{ basis: 'leisure', title: 'Leisure', blurb: 'Slow, comfortable 100%' },
|
|
||||||
{ basis: 'worst', title: 'Worst case', blurb: 'Max recorded time' },
|
|
||||||
{ basis: 'pace', title: 'At your pace', blurb: 'Based on games finished' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const STYLE_LABELS: Record<string, string> = {
|
|
||||||
faster_than_rush: 'Faster than rush',
|
|
||||||
rush_to_leisure: 'Between rush and leisure',
|
|
||||||
slower_than_leisure: 'Slower than leisure',
|
|
||||||
unknown: 'Unknown',
|
|
||||||
}
|
|
||||||
|
|
||||||
function TargetBanner({ result, filters }: Props) {
|
|
||||||
if (!filters.targetDate) return null
|
|
||||||
const now = new Date()
|
|
||||||
const target = new Date(filters.targetDate)
|
|
||||||
const days = Math.max(1, Math.ceil((target.getTime() - now.getTime()) / 86400000))
|
|
||||||
let need: string
|
|
||||||
if (filters.basis === 'pace') {
|
|
||||||
const perDay = result.remainingGames / days
|
|
||||||
need = `${perDay.toFixed(2)} games/day`
|
|
||||||
} else {
|
|
||||||
const total = basisTotal(result, filters.basis) as number
|
|
||||||
need = `${(total / days).toFixed(1)} h/day`
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="target-banner">
|
|
||||||
To finish <strong>{result.remainingGames}</strong> games by{' '}
|
|
||||||
<strong>{filters.targetDate}</strong> ({days} days) on the{' '}
|
|
||||||
<strong>{filters.basis}</strong> model, you need <strong>{need}</strong>.
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function PlayerSpeedInsight({
|
|
||||||
result,
|
|
||||||
paceVsHltb,
|
|
||||||
presets,
|
|
||||||
}: Pick<Props, 'result' | 'paceVsHltb' | 'presets'>) {
|
|
||||||
const pace = paceVsHltb
|
|
||||||
const estimated = playerEstimatedTotal(result.rushTotal, result.leisureTotal, pace)
|
|
||||||
|
|
||||||
if (!pace || pace.calibration_count === 0) {
|
|
||||||
return (
|
|
||||||
<div className="player-insight player-insight--empty">
|
|
||||||
<div className="player-insight-title">Your Play Style vs HLTB</div>
|
|
||||||
<p className="player-insight-empty">
|
|
||||||
No calibration data yet. Finish games (100% achievements) and re-run{' '}
|
|
||||||
<code>stats</code> to see your pace estimate.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="player-insight">
|
|
||||||
<div className="player-insight-title">Your Play Style vs HLTB</div>
|
|
||||||
<div className="player-insight-grid">
|
|
||||||
<span>Calibration games</span>
|
|
||||||
<span>{pace.calibration_count}</span>
|
|
||||||
|
|
||||||
{pace.ratio_vs_rush !== -1 && (
|
|
||||||
<>
|
|
||||||
<span>vs Rush speed</span>
|
|
||||||
<span
|
|
||||||
className={pace.ratio_vs_rush <= 1 ? 'player-insight-fast' : 'player-insight-slow'}
|
|
||||||
>
|
|
||||||
{pace.ratio_vs_rush.toFixed(2)}×
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pace.ratio_vs_leisure !== -1 && (
|
|
||||||
<>
|
|
||||||
<span>vs Leisure speed</span>
|
|
||||||
<span>{pace.ratio_vs_leisure.toFixed(2)}×</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pace.interpolation_t !== -1 && (
|
|
||||||
<>
|
|
||||||
<span>Interpolation t</span>
|
|
||||||
<span title="0 = rush speed · 1 = leisure speed">
|
|
||||||
{pace.interpolation_t.toFixed(3)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span>Play style</span>
|
|
||||||
<span className={`player-insight-style player-insight-style--${pace.player_style}`}>
|
|
||||||
{STYLE_LABELS[pace.player_style] ?? pace.player_style}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{estimated !== null && (
|
|
||||||
<div className="player-insight-estimate">
|
|
||||||
<div className="player-insight-estimate-total">
|
|
||||||
Estimated total at your pace:{' '}
|
|
||||||
<strong className="player-insight-estimate-hours">{fmtHours(estimated)}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="presets">
|
|
||||||
{presets.map((h) => {
|
|
||||||
const days = estimated > 0 ? Math.floor(estimated / h) : null
|
|
||||||
return (
|
|
||||||
<div key={h} className="preset">
|
|
||||||
<span>{h} h/day</span>
|
|
||||||
<span>{fmtEta(days)}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SummaryCards(props: Props) {
|
|
||||||
const { result, filters, state, presets, paceVsHltb } = props
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="summary">
|
|
||||||
<div className="summary-head">
|
|
||||||
<div>
|
|
||||||
<span className="big">{result.remainingGames.toLocaleString()}</span>
|
|
||||||
<span className="big-label">games in scope</span>
|
|
||||||
</div>
|
|
||||||
<div className="parity">
|
|
||||||
CLI default qualifies <strong>{props.defaultQualifying}</strong>
|
|
||||||
{result.remainingGames === props.defaultQualifying && (
|
|
||||||
<span className="ok"> ✓ match</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TargetBanner {...props} />
|
|
||||||
|
|
||||||
<div className="cards">
|
|
||||||
{CARDS.map((c) => {
|
|
||||||
const active = filters.basis === c.basis
|
|
||||||
const isPace = c.basis === 'pace'
|
|
||||||
const total = basisTotal(result, c.basis)
|
|
||||||
const headlineEta = isPace
|
|
||||||
? paceDays(result.remainingGames, state.pace_games_per_day)
|
|
||||||
: etaDays(total as number, filters.dailyHours)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={c.basis} className={active ? 'card active' : 'card'}>
|
|
||||||
<div className="card-title">{c.title}</div>
|
|
||||||
<div className="card-blurb">{c.blurb}</div>
|
|
||||||
<div className="card-total">
|
|
||||||
{isPace
|
|
||||||
? `${state.pace_games_per_day || 0} games/day`
|
|
||||||
: fmtHours(total as number)}
|
|
||||||
</div>
|
|
||||||
<div className="card-eta">
|
|
||||||
{isPace && !state.pace_games_per_day
|
|
||||||
? 'No start date set'
|
|
||||||
: fmtEta(headlineEta)}
|
|
||||||
</div>
|
|
||||||
{!isPace && (
|
|
||||||
<div className="presets">
|
|
||||||
{presets.map((h) => (
|
|
||||||
<div key={h} className="preset">
|
|
||||||
<span>{h} h/day</span>
|
|
||||||
<span>{fmtEta(etaDays(total as number, h))}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<PlayerSpeedInsight result={result} paceVsHltb={paceVsHltb} presets={presets} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import { applyFilters } from '../estimate'
|
|
||||||
import type { EstimateBasis } from '../types'
|
|
||||||
import { makeDataset, makeFilters, makeGame, makeState } from '../test/factories'
|
|
||||||
import { TimelineChart } from './TimelineChart'
|
|
||||||
|
|
||||||
function renderChart(
|
|
||||||
count: number,
|
|
||||||
basis: EstimateBasis = 'leisure',
|
|
||||||
pace = 0,
|
|
||||||
) {
|
|
||||||
const games = Array.from({ length: count }, (_, i) =>
|
|
||||||
makeGame({ app_id: i + 1, name: `G${i}`, leisure_hours: 10 + i }),
|
|
||||||
)
|
|
||||||
const filters = makeFilters({ basis })
|
|
||||||
const result = applyFilters(makeDataset(games), filters)
|
|
||||||
return render(
|
|
||||||
<TimelineChart
|
|
||||||
result={result}
|
|
||||||
filters={filters}
|
|
||||||
state={makeState({ pace_games_per_day: pace })}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('TimelineChart', () => {
|
|
||||||
it('shows a fallback message with fewer than two games', () => {
|
|
||||||
renderChart(1)
|
|
||||||
expect(screen.getByText(/Not enough games/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('draws an SVG line for the hours basis', () => {
|
|
||||||
const { container } = renderChart(3, 'leisure')
|
|
||||||
expect(container.querySelector('svg.chart-svg')).not.toBeNull()
|
|
||||||
expect(container.querySelector('path.line')).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('draws an SVG line for the pace basis when pace is known', () => {
|
|
||||||
const { container } = renderChart(3, 'pace', 0.5)
|
|
||||||
expect(container.querySelector('svg.chart-svg')).not.toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the fallback message for the pace basis with no pace', () => {
|
|
||||||
renderChart(3, 'pace', 0)
|
|
||||||
expect(screen.getByText(/Not enough games/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders when the last timeline point has day=0 (covers || 1 fallback)', () => {
|
|
||||||
// Game 1: leisure_hours=0 but rush>0 so not noData → lengthHours=-1.
|
|
||||||
// Game 2: leisure_hours=1 → lengthHours=1.
|
|
||||||
// Cumulative with dailyHours=1: [-1, 0]. Last day=0 triggers `|| 1`.
|
|
||||||
const games = [
|
|
||||||
makeGame({ app_id: 1, name: 'G0', leisure_hours: 0 }),
|
|
||||||
makeGame({ app_id: 2, name: 'G1', leisure_hours: 1 }),
|
|
||||||
]
|
|
||||||
const filters = makeFilters({ basis: 'leisure', dailyHours: 1 })
|
|
||||||
const result = applyFilters(makeDataset(games), filters)
|
|
||||||
const { container } = render(
|
|
||||||
<TimelineChart result={result} filters={filters} state={makeState()} />,
|
|
||||||
)
|
|
||||||
expect(container.querySelector('svg.chart-svg')).not.toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
import type { EstimateResult } from '../estimate'
|
|
||||||
import { isoDate } from '../format'
|
|
||||||
import type { Filters, WebStateInfo } from '../types'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
result: EstimateResult
|
|
||||||
filters: Filters
|
|
||||||
state: WebStateInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
const W = 820
|
|
||||||
const H = 320
|
|
||||||
const PAD = { top: 16, right: 20, bottom: 36, left: 48 }
|
|
||||||
|
|
||||||
interface Point {
|
|
||||||
day: number
|
|
||||||
games: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the cumulative "games finished by day N" curve for the basis. */
|
|
||||||
function buildPoints(props: Props): Point[] {
|
|
||||||
const { result, filters, state } = props
|
|
||||||
const rows = [...result.included].sort((a, b) => a.lengthHours - b.lengthHours)
|
|
||||||
const pts: Point[] = []
|
|
||||||
if (filters.basis === 'pace') {
|
|
||||||
const pace = state.pace_games_per_day
|
|
||||||
if (pace <= 0) return []
|
|
||||||
rows.forEach((_, i) => pts.push({ day: (i + 1) / pace, games: i + 1 }))
|
|
||||||
return pts
|
|
||||||
}
|
|
||||||
let cum = 0
|
|
||||||
rows.forEach((r, i) => {
|
|
||||||
cum += r.lengthHours
|
|
||||||
pts.push({ day: cum / filters.dailyHours, games: i + 1 })
|
|
||||||
})
|
|
||||||
return pts
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayToDate(day: number): string {
|
|
||||||
const d = new Date()
|
|
||||||
d.setDate(d.getDate() + Math.round(day))
|
|
||||||
return isoDate(d)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TimelineChart(props: Props) {
|
|
||||||
const pts = buildPoints(props)
|
|
||||||
if (pts.length < 2) {
|
|
||||||
return (
|
|
||||||
<div className="chart">
|
|
||||||
<h2>Completion timeline</h2>
|
|
||||||
<p className="hint">Not enough games in scope to draw a timeline.</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxDay = pts[pts.length - 1].day || 1
|
|
||||||
const maxGames = pts.length
|
|
||||||
const plotW = W - PAD.left - PAD.right
|
|
||||||
const plotH = H - PAD.top - PAD.bottom
|
|
||||||
const sx = (day: number) => PAD.left + (day / maxDay) * plotW
|
|
||||||
const sy = (games: number) => PAD.top + plotH - (games / maxGames) * plotH
|
|
||||||
|
|
||||||
const path = pts
|
|
||||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.day).toFixed(1)} ${sy(p.games).toFixed(1)}`)
|
|
||||||
.join(' ')
|
|
||||||
const area = `${path} L${sx(maxDay).toFixed(1)} ${sy(0).toFixed(1)} L${sx(0).toFixed(1)} ${sy(0).toFixed(1)} Z`
|
|
||||||
|
|
||||||
const xTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => f * maxDay)
|
|
||||||
const yTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => Math.round(f * maxGames))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="chart">
|
|
||||||
<h2>Completion timeline · {props.filters.basis}</h2>
|
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" role="img">
|
|
||||||
{yTicks.map((g) => (
|
|
||||||
<g key={`y${g}`}>
|
|
||||||
<line
|
|
||||||
x1={PAD.left}
|
|
||||||
y1={sy(g)}
|
|
||||||
x2={W - PAD.right}
|
|
||||||
y2={sy(g)}
|
|
||||||
className="grid"
|
|
||||||
/>
|
|
||||||
<text x={PAD.left - 8} y={sy(g) + 4} className="axis-label end">
|
|
||||||
{g}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
{xTicks.map((d) => (
|
|
||||||
<text key={`x${d}`} x={sx(d)} y={H - 12} className="axis-label mid">
|
|
||||||
{dayToDate(d)}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
<path d={area} className="area" />
|
|
||||||
<path d={path} className="line" />
|
|
||||||
</svg>
|
|
||||||
<p className="hint">
|
|
||||||
Cumulative games finished over time (shortest games first).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
14
web/src/elements/shell.test.ts
Normal file
14
web/src/elements/shell.test.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
const boot = vi.hoisted(() => vi.fn())
|
||||||
|
vi.mock('../shell/boot', () => ({ boot }))
|
||||||
|
|
||||||
|
describe('shell entry', () => {
|
||||||
|
it('registers the shell elements and boots the page', async () => {
|
||||||
|
await import('./shell')
|
||||||
|
for (const tag of ['command-button', 'command-card', 'command-palette', 'runner-dialog', 'job-progress', 'game-browser', 'health-badges', 'stale-banner', 'job-indicator']) {
|
||||||
|
expect(customElements.get(tag), tag).toBeDefined()
|
||||||
|
}
|
||||||
|
expect(boot).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
11
web/src/elements/shell.ts
Normal file
11
web/src/elements/shell.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Registers every element the shell uses, then wires page-wide behaviour.
|
||||||
|
import '../commands/commandButton'
|
||||||
|
import '../commands/palette'
|
||||||
|
import '../commands/runnerDialog'
|
||||||
|
import '../jobs/jobProgress'
|
||||||
|
import '../library/gameBrowser'
|
||||||
|
import '../shell/health'
|
||||||
|
import '../shell/jobIndicator'
|
||||||
|
import { boot } from '../shell/boot'
|
||||||
|
|
||||||
|
boot()
|
||||||
2
web/src/env.d.ts
vendored
Normal file
2
web/src/env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
|
/// <reference types="astro/client" />
|
||||||
@ -1,113 +0,0 @@
|
|||||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { queryClient } from '../api/queries'
|
|
||||||
import { makeLibGame } from '../test/fixtures'
|
|
||||||
import { apiError, bodiesOf, renderClient, stubApi } from '../test/harness'
|
|
||||||
import { GamePrompt } from './GamePrompt'
|
|
||||||
import { hub, type PromptEvent } from './hub'
|
|
||||||
import { PromptForm } from './PromptForm'
|
|
||||||
|
|
||||||
const GAMES = [
|
|
||||||
makeLibGame({ app_id: 1, name: 'Hollow Knight' }),
|
|
||||||
makeLibGame({ app_id: 2, name: 'The Witcher 2' }),
|
|
||||||
makeLibGame({ app_id: 3, name: 'Age of Empires III', ineligible_reason: 'Not owned for long enough' }),
|
|
||||||
]
|
|
||||||
const prompt = (over: Partial<PromptEvent> = {}): PromptEvent => ({
|
|
||||||
seq: 1, ts: 't', type: 'prompt', prompt_id: 'g1', kind: 'game', message: 'Pick a game', app_ids: [1, 2], ...over,
|
|
||||||
})
|
|
||||||
const ANSWER = 'POST /api/jobs/j/answer'
|
|
||||||
const card = (name: string) => screen.getByRole('button', { name: new RegExp(`^${name}`) })
|
|
||||||
|
|
||||||
describe('GamePrompt', () => {
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
|
||||||
|
|
||||||
it('is rendered by PromptForm for a game prompt', async () => {
|
|
||||||
stubApi({ 'GET /api/library': { games: GAMES } })
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt()} />)
|
|
||||||
expect(await screen.findByRole('form', { name: 'Job question' })).toHaveTextContent('2 games can be picked')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('selecting a card never submits; only the Pick button does', async () => {
|
|
||||||
const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined })
|
|
||||||
const answered = vi.spyOn(hub, 'answered')
|
|
||||||
renderClient(<GamePrompt jobId="j" prompt={prompt()} />)
|
|
||||||
await screen.findByText('3 of 3 games')
|
|
||||||
expect(screen.getByRole('button', { name: 'Select a game' })).toBeDisabled()
|
|
||||||
await userEvent.click(card('The Witcher 2'))
|
|
||||||
await userEvent.dblClick(card('The Witcher 2'))
|
|
||||||
expect(bodiesOf(mock, ANSWER)).toEqual([])
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Pick The Witcher 2' }))
|
|
||||||
await waitFor(() => expect(bodiesOf(mock, ANSWER)).toEqual([{ prompt_id: 'g1', value: '2' }]))
|
|
||||||
await waitFor(() => expect(answered).toHaveBeenCalledWith('j', 'g1'))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('only offers the app ids the job sent', async () => {
|
|
||||||
const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined })
|
|
||||||
renderClient(<GamePrompt jobId="j" prompt={prompt()} />)
|
|
||||||
await screen.findByText('3 of 3 games')
|
|
||||||
await userEvent.click(card('Age of Empires III'))
|
|
||||||
expect(screen.getByRole('button', { name: 'Select a game' })).toBeDisabled()
|
|
||||||
expect(card('Age of Empires III')).toHaveTextContent('Not owned for long enough')
|
|
||||||
expect(card('Hollow Knight')).not.toHaveAttribute('aria-disabled')
|
|
||||||
expect(bodiesOf(mock, ANSWER)).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('"Back to the list" answers an empty string', async () => {
|
|
||||||
const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined })
|
|
||||||
renderClient(<GamePrompt jobId="j" prompt={prompt()} />)
|
|
||||||
await screen.findByText('3 of 3 games')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Back to the list' }))
|
|
||||||
await waitFor(() => expect(bodiesOf(mock, ANSWER)).toEqual([{ prompt_id: 'g1', value: '' }]))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('Enter in the form submits the selection, and does nothing without one', async () => {
|
|
||||||
const mock = stubApi({ 'GET /api/library': { games: GAMES }, [ANSWER]: undefined })
|
|
||||||
renderClient(<GamePrompt jobId="j" prompt={prompt()} />)
|
|
||||||
await screen.findByText('3 of 3 games')
|
|
||||||
const form = screen.getByRole('form', { name: 'Job question' })
|
|
||||||
fireEvent.submit(form)
|
|
||||||
expect(bodiesOf(mock, ANSWER)).toEqual([])
|
|
||||||
await userEvent.click(card('Hollow Knight'))
|
|
||||||
fireEvent.submit(form)
|
|
||||||
await waitFor(() => expect(bodiesOf(mock, ANSWER)).toEqual([{ prompt_id: 'g1', value: '1' }]))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('names the game by id if it vanished from the library meanwhile', async () => {
|
|
||||||
const routes = { 'GET /api/library': { games: GAMES }, [ANSWER]: undefined }
|
|
||||||
stubApi(routes)
|
|
||||||
renderClient(<GamePrompt jobId="j" prompt={prompt()} />)
|
|
||||||
await screen.findByText('3 of 3 games')
|
|
||||||
await userEvent.click(card('Hollow Knight'))
|
|
||||||
routes['GET /api/library'] = { games: [GAMES[1]] }
|
|
||||||
await act(async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['library'] })
|
|
||||||
})
|
|
||||||
expect(await screen.findByRole('button', { name: 'Pick 1' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('copes with a prompt that carries no app ids', async () => {
|
|
||||||
stubApi({ 'GET /api/library': { games: GAMES } })
|
|
||||||
renderClient(<GamePrompt jobId="j" prompt={prompt({ app_ids: undefined })} />)
|
|
||||||
expect(await screen.findByText(/0 games can be picked/)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the error when the answer is refused, and Sending… meanwhile', async () => {
|
|
||||||
let fail = false
|
|
||||||
let release: () => void = () => {}
|
|
||||||
stubApi({
|
|
||||||
'GET /api/library': { games: GAMES },
|
|
||||||
[ANSWER]: () => (fail ? apiError(409, 'busy', 'job moved on') : new Promise<void>((r) => (release = r))),
|
|
||||||
})
|
|
||||||
renderClient(<GamePrompt jobId="j" prompt={prompt()} />)
|
|
||||||
await screen.findByText('3 of 3 games')
|
|
||||||
await userEvent.click(card('Hollow Knight'))
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Pick Hollow Knight' }))
|
|
||||||
expect(await screen.findByRole('button', { name: 'Sending…' })).toBeDisabled()
|
|
||||||
expect(screen.getByRole('button', { name: 'Back to the list' })).toBeDisabled()
|
|
||||||
fail = true
|
|
||||||
await act(async () => release())
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Back to the list' }))
|
|
||||||
expect(await screen.findByText('job moved on')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { api } from '../api/client'
|
|
||||||
import { q } from '../api/queries'
|
|
||||||
import { GameBrowser } from '../library/GameBrowser'
|
|
||||||
import { ErrorNotice } from '../ui/Notice'
|
|
||||||
import { hub, type PromptEvent } from './hub'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A `game` prompt: the library browser in pick mode. Only the ids the job
|
|
||||||
* sent are selectable (the job re-checks the answer). "Back to the list"
|
|
||||||
* answers empty, which returns the job to its previous question.
|
|
||||||
*/
|
|
||||||
export function GamePrompt({ jobId, prompt }: { jobId: string; prompt: PromptEvent }) {
|
|
||||||
const [appIds] = useState(() => new Set(prompt.app_ids ?? []))
|
|
||||||
const [selected, setSelected] = useState<number | null>(null)
|
|
||||||
const { data } = useQuery(q.library)
|
|
||||||
const send = useMutation({
|
|
||||||
mutationFn: (v: string) => api.answer(jobId, { prompt_id: prompt.prompt_id, value: v }),
|
|
||||||
onSuccess: () => hub.answered(jobId, prompt.prompt_id),
|
|
||||||
})
|
|
||||||
const name = data?.games.find((g) => g.app_id === selected)?.name ?? String(selected)
|
|
||||||
const submit = (id: number) => send.mutate(String(id))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form
|
|
||||||
className="prompt prompt-game"
|
|
||||||
aria-label="Job question"
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (selected !== null) submit(selected)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p className="prompt-msg">
|
|
||||||
{prompt.message} <span className="muted">· {appIds.size} games can be picked</span>
|
|
||||||
</p>
|
|
||||||
<GameBrowser pick={{ appIds, selected, onSelect: setSelected }} />
|
|
||||||
{send.error && <ErrorNotice error={send.error} />}
|
|
||||||
<div className="actions prompt-game-bar">
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={selected === null || send.isPending}>
|
|
||||||
{send.isPending ? 'Sending…' : selected === null ? 'Select a game' : `Pick ${name}`}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-ghost" disabled={send.isPending} onClick={() => send.mutate('')}>
|
|
||||||
Back to the list
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,17 +0,0 @@
|
|||||||
import { screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
import { renderClient, stubApi } from '../test/harness'
|
|
||||||
import { JobProgress } from './JobProgress'
|
|
||||||
|
|
||||||
// useLiveJob returns null only for a null id, which JobProgress never passes.
|
|
||||||
// JobProgress still guards the type, so pin that guard.
|
|
||||||
vi.mock('./useLiveJob', () => ({ useLiveJob: () => null }))
|
|
||||||
|
|
||||||
describe('JobProgress without a live stream handle', () => {
|
|
||||||
it('renders nothing', () => {
|
|
||||||
stubApi({ 'GET /api/jobs/x': {}, 'GET /api/commands': [] })
|
|
||||||
renderClient(<JobProgress jobId="x" />)
|
|
||||||
expect(screen.queryByRole('region')).toBeNull()
|
|
||||||
expect(document.querySelector('section')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
import { act, screen } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { makeJob, makeSpec } from '../test/fixtures'
|
|
||||||
import { apiError, FakeEventSource, installEventSource, renderClient, stubApi } from '../test/harness'
|
|
||||||
import { JobProgress } from './JobProgress'
|
|
||||||
|
|
||||||
let n = 0
|
|
||||||
const ts = '2026-10-10T10:00:00Z'
|
|
||||||
|
|
||||||
/** Mount a JobProgress for a fresh job id; `send` pushes SSE events into it. */
|
|
||||||
function mount(opts: { job?: Parameters<typeof makeJob>[0]; cancellable?: boolean; compact?: boolean; extra?: Record<string, unknown> } = {}) {
|
|
||||||
const id = `jp-${++n}`
|
|
||||||
const routes: Record<string, unknown> = {
|
|
||||||
[`GET /api/jobs/${id}`]: makeJob({ id, command: 'scan', ...opts.job }),
|
|
||||||
'GET /api/commands': [makeSpec('scan', { cancellable: opts.cancellable ?? false })],
|
|
||||||
...opts.extra,
|
|
||||||
}
|
|
||||||
const mock = stubApi(routes)
|
|
||||||
renderClient(<JobProgress jobId={id} compact={opts.compact} />)
|
|
||||||
const es = FakeEventSource.last
|
|
||||||
let seq = 0
|
|
||||||
const send = (type: string, body: Record<string, unknown>) => act(() => es.emit(type, { seq: ++seq, ts, type, ...body }))
|
|
||||||
return { id, es, send, mock, routes }
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('JobProgress', () => {
|
|
||||||
beforeEach(installEventSource)
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers()
|
|
||||||
vi.unstubAllGlobals()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('starts from the fetched job and shows "Starting…" before any progress', async () => {
|
|
||||||
mount({ job: { state: 'running' } })
|
|
||||||
expect(screen.getByText('Starting…')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Queued')).toBeInTheDocument() // until the job loads
|
|
||||||
expect(await screen.findByText('Running')).toBeInTheDocument()
|
|
||||||
expect(await screen.findByRole('heading', { name: 'Scan' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows step, item and the job’s own estimate', async () => {
|
|
||||||
const { send } = mount()
|
|
||||||
await send('progress', { step: 3, total: 10, label: 'Scanning library', item: 'Hollow Knight', eta_seconds: 9 })
|
|
||||||
expect(screen.getByText('Scanning library', { selector: '.job-label' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Step 3 of 10')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Hollow Knight')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('about 0:09 left')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '30')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('says "working…" for an unbounded phase', async () => {
|
|
||||||
const { send } = mount()
|
|
||||||
await send('progress', { step: 4, total: null, label: 'Reading' })
|
|
||||||
expect(screen.getByText('working…')).toBeInTheDocument()
|
|
||||||
expect(screen.queryByText(/^Step/)).toBeNull()
|
|
||||||
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows nothing for an empty total and no estimate yet', async () => {
|
|
||||||
const { send } = mount()
|
|
||||||
await send('progress', { step: 0, total: 0, label: 'Preparing' })
|
|
||||||
expect(document.querySelector('.job-eta')).toHaveTextContent('')
|
|
||||||
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('extrapolates the remaining time from the pace when the job gives none', async () => {
|
|
||||||
vi.useFakeTimers({ now: Date.parse(ts) })
|
|
||||||
const { send } = mount()
|
|
||||||
await send('progress', { step: 1, total: 5, label: 'Working' })
|
|
||||||
await act(async () => {
|
|
||||||
await vi.advanceTimersByTimeAsync(10_000)
|
|
||||||
})
|
|
||||||
await send('progress', { step: 2, total: 5, label: 'Working' })
|
|
||||||
// 10 s for 2 steps, 3 to go: 15 s.
|
|
||||||
expect(screen.getByText('about 0:15 left')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('has no estimate for the first step of zero', async () => {
|
|
||||||
const { send } = mount()
|
|
||||||
await send('progress', { step: 0, total: 5, label: 'Working' })
|
|
||||||
expect(document.querySelector('.job-eta')).toHaveTextContent('')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to the fetched job’s progress, with a null estimate ignored', async () => {
|
|
||||||
mount({ job: { progress: { step: 2, total: 4, label: 'From server', eta_seconds: null as unknown as undefined } } })
|
|
||||||
expect(await screen.findByText('From server', { selector: '.job-label' })).toBeInTheDocument()
|
|
||||||
expect(document.querySelector('.job-eta')).toHaveTextContent('') // no pace data either
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the connection state', async () => {
|
|
||||||
const { es } = mount()
|
|
||||||
expect(screen.getByText('Reconnecting')).toBeInTheDocument()
|
|
||||||
act(() => es.onopen?.())
|
|
||||||
expect(screen.getByText('Live')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('waiting for input', () => {
|
|
||||||
it('shows the question, pauses, and lets even a non-cancellable job be cancelled', async () => {
|
|
||||||
const { send, mock } = mount({ extra: { 'POST /api/jobs/jp-1/cancel': undefined } })
|
|
||||||
mock.mockClear()
|
|
||||||
await send('prompt', { prompt_id: 'q', kind: 'text', message: 'Name?' })
|
|
||||||
await send('state', { state: 'waiting_input' })
|
|
||||||
expect(screen.getByText('Waiting for your answer')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('paused')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('form', { name: 'Job question' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: 'Cancel job' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cancels through the API and shows a failure', async () => {
|
|
||||||
const { send, routes, id } = mount({ cancellable: true })
|
|
||||||
routes[`POST /api/jobs/${id}/cancel`] = apiError(409, 'not_cancellable', 'too late')
|
|
||||||
await send('state', { state: 'running' })
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Cancel job' }))
|
|
||||||
expect(await screen.findByText('too late')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Cancelling… while the request runs', async () => {
|
|
||||||
let release: () => void = () => {}
|
|
||||||
const { send, routes, id } = mount({ cancellable: true })
|
|
||||||
routes[`POST /api/jobs/${id}/cancel`] = () => new Promise<void>((r) => (release = r))
|
|
||||||
await send('state', { state: 'running' })
|
|
||||||
await userEvent.click(await screen.findByRole('button', { name: 'Cancel job' }))
|
|
||||||
expect(await screen.findByRole('button', { name: 'Cancelling…' })).toBeDisabled()
|
|
||||||
await act(async () => release())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('when finished', () => {
|
|
||||||
it('reports success with the summary, full bar and an open log', async () => {
|
|
||||||
const { send } = mount({ cancellable: true })
|
|
||||||
await send('state', { state: 'succeeded' })
|
|
||||||
await send('result', { ok: true, summary: 'All 12 scanned' })
|
|
||||||
expect(screen.getByText('Completed')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Done')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('All 12 scanned')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100')
|
|
||||||
expect(screen.queryByRole('button', { name: 'Cancel job' })).toBeNull()
|
|
||||||
expect(document.querySelector('details')).toHaveAttribute('open')
|
|
||||||
expect(screen.getByText('No output.')).toBeInTheDocument()
|
|
||||||
expect(document.querySelector('.job-sub')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports failure and cancellation', async () => {
|
|
||||||
const failed = mount()
|
|
||||||
await failed.send('state', { state: 'failed' })
|
|
||||||
await failed.send('result', { ok: false, summary: 'boom' })
|
|
||||||
expect(screen.getByText('Did not complete')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Failed', { selector: '.job-label' })).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('progressbar').className).toContain('bar-danger')
|
|
||||||
const cancelled = mount()
|
|
||||||
await cancelled.send('state', { state: 'cancelled' })
|
|
||||||
expect(screen.getByText('Cancelled', { selector: '.job-label' })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the success tone', async () => {
|
|
||||||
const { send } = mount()
|
|
||||||
await send('state', { state: 'succeeded' })
|
|
||||||
expect(screen.getByRole('progressbar').className).toContain('bar-success')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('log', () => {
|
|
||||||
it('lists lines with their time and counts them', async () => {
|
|
||||||
const { send } = mount()
|
|
||||||
await send('log', { level: 'info', message: 'hello' })
|
|
||||||
await send('log', { level: 'warning', message: 'careful' })
|
|
||||||
expect(screen.getByText('Log (2 lines)')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('hello')).toBeInTheDocument()
|
|
||||||
expect(screen.getAllByText('10:00:00')).toHaveLength(2)
|
|
||||||
expect(document.querySelector('details')).not.toHaveAttribute('open')
|
|
||||||
expect(screen.queryByText('No output yet.')).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('opens by itself on an error line, but not in compact mode', async () => {
|
|
||||||
const full = mount()
|
|
||||||
await full.send('log', { level: 'error', message: 'bad' })
|
|
||||||
expect(document.querySelector('details')).toHaveAttribute('open')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stays closed in compact mode', async () => {
|
|
||||||
const { send } = mount({ compact: true })
|
|
||||||
await send('log', { level: 'error', message: 'bad' })
|
|
||||||
expect(document.querySelector('details')).not.toHaveAttribute('open')
|
|
||||||
expect(document.querySelector('section')).toHaveClass('job-compact')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('says nothing has been printed yet', () => {
|
|
||||||
mount()
|
|
||||||
expect(screen.getByText('No output yet.')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { api } from '../api/client'
|
|
||||||
import type { JobProgress as Progress, JobState } from '../api/jobContract'
|
|
||||||
import { q } from '../api/queries'
|
|
||||||
import { commandLabel, findCommand } from '../commands/catalog'
|
|
||||||
import { clock, duration } from '../ui/format'
|
|
||||||
import { ErrorNotice, Notice } from '../ui/Notice'
|
|
||||||
import { useNow } from '../ui/useNow'
|
|
||||||
import type { LiveJob } from './hub'
|
|
||||||
import { ProgressBar, StateBadge } from './ProgressBar'
|
|
||||||
import { PromptForm } from './PromptForm'
|
|
||||||
import { useLiveJob } from './useLiveJob'
|
|
||||||
|
|
||||||
/** Remaining seconds: the job's own estimate, else extrapolated from pace. */
|
|
||||||
function eta(p: Progress, live: LiveJob, now: number): number | null {
|
|
||||||
if (p.eta_seconds !== undefined && p.eta_seconds !== null) return p.eta_seconds
|
|
||||||
if (p.total === null || p.step <= 0 || live.startedProgressAt === null) return null
|
|
||||||
const perStep = (now / 1000 - live.startedProgressAt) / p.step
|
|
||||||
return perStep * (p.total - p.step)
|
|
||||||
}
|
|
||||||
|
|
||||||
const DONE: ReadonlySet<JobState> = new Set(['succeeded', 'failed', 'cancelled'])
|
|
||||||
const FINISHED_LABEL: Partial<Record<JobState, string>> = { succeeded: 'Completed', failed: 'Failed', cancelled: 'Cancelled' }
|
|
||||||
|
|
||||||
export function JobProgress({ jobId, compact = false }: { jobId: string; compact?: boolean }) {
|
|
||||||
const live = useLiveJob(jobId)
|
|
||||||
const { data: job } = useQuery(q.job(jobId))
|
|
||||||
const { data: commands } = useQuery(q.commands)
|
|
||||||
const now = useNow(1000)
|
|
||||||
const logRef = useRef<HTMLOListElement>(null)
|
|
||||||
const cancel = useMutation({ mutationFn: () => api.cancelJob(jobId) })
|
|
||||||
|
|
||||||
const logCount = live?.logs.length ?? 0
|
|
||||||
useEffect(() => {
|
|
||||||
const el = logRef.current
|
|
||||||
if (el) el.scrollTop = el.scrollHeight
|
|
||||||
}, [logCount])
|
|
||||||
|
|
||||||
if (!live) return null
|
|
||||||
const state: JobState = live.state ?? job?.state ?? 'queued'
|
|
||||||
const progress = live.progress ?? job?.progress ?? null
|
|
||||||
const finished = DONE.has(state)
|
|
||||||
const spec = job ? findCommand(commands, job.command) : undefined
|
|
||||||
const fraction = finished
|
|
||||||
? 1
|
|
||||||
: progress && progress.total !== null && progress.total > 0
|
|
||||||
? progress.step / progress.total
|
|
||||||
: null
|
|
||||||
const tone = state === 'failed' ? 'danger' : state === 'succeeded' ? 'success' : state === 'waiting_input' ? 'warning' : 'accent'
|
|
||||||
const waiting = state === 'waiting_input'
|
|
||||||
const remaining = progress && !finished && !waiting ? eta(progress, live, now) : null
|
|
||||||
const hasErrors = live.logs.some((l) => l.level === 'error')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className={compact ? 'job job-compact' : 'job'} aria-label={`Job ${job ? commandLabel(job.command) : jobId}`}>
|
|
||||||
<div className="job-head">
|
|
||||||
<h3 className="job-title">{job ? commandLabel(job.command) : 'Job'}</h3>
|
|
||||||
<StateBadge state={state} />
|
|
||||||
<span className="job-meta">
|
|
||||||
{job && duration(job.started_at, job.ended_at, now)}
|
|
||||||
{!finished && (
|
|
||||||
<span className={live.connected ? 'dot dot-live' : 'dot dot-off'} title={live.connected ? 'Live' : 'Reconnecting…'}>
|
|
||||||
<span className="sr-only">{live.connected ? 'Live' : 'Reconnecting'}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="job-phase" aria-live="polite">
|
|
||||||
<span className="job-label">{finished ? FINISHED_LABEL[state] : waiting ? 'Waiting for your answer' : (progress?.label ?? 'Starting…')}</span>
|
|
||||||
{!finished && progress && progress.total !== null && (
|
|
||||||
<span className="job-step">
|
|
||||||
Step {progress.step} of {progress.total}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ProgressBar fraction={fraction} label={progress?.label ?? 'Progress'} tone={tone} />
|
|
||||||
{!finished && (
|
|
||||||
<div className="job-sub">
|
|
||||||
<span className="job-item">{progress?.item ?? ' '}</span>
|
|
||||||
<span className="job-eta">{remaining !== null ? `about ${clock(remaining)} left` : waiting ? 'paused' : progress?.total === null ? 'working…' : ''}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{live.prompt && state === 'waiting_input' && <PromptForm key={live.prompt.prompt_id} jobId={jobId} prompt={live.prompt} />}
|
|
||||||
|
|
||||||
{live.result && (
|
|
||||||
<Notice tone={live.result.ok ? 'success' : 'danger'} title={live.result.ok ? 'Done' : 'Did not complete'}>
|
|
||||||
{live.result.summary}
|
|
||||||
</Notice>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cancel.error && <ErrorNotice error={cancel.error} />}
|
|
||||||
{(spec?.cancellable || waiting) && !finished && (
|
|
||||||
<div className="actions">
|
|
||||||
<button type="button" className="btn btn-ghost" disabled={cancel.isPending} onClick={() => cancel.mutate()}>
|
|
||||||
{cancel.isPending ? 'Cancelling…' : 'Cancel job'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<details className="job-log" open={!compact && (hasErrors || finished) ? true : undefined}>
|
|
||||||
<summary>Log ({logCount} lines)</summary>
|
|
||||||
<ol ref={logRef} className="log" tabIndex={0} aria-label="Job log">
|
|
||||||
{live.logs.map((l) => (
|
|
||||||
<li key={l.seq} className={`log-${l.level}`}>
|
|
||||||
<time dateTime={l.ts}>{l.ts.slice(11, 19)}</time> {l.message}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{logCount === 0 && <li className="muted">{finished ? 'No output.' : 'No output yet.'}</li>}
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { render, screen } from '@testing-library/react'
|
|
||||||
import { describe, expect, it } from 'vitest'
|
|
||||||
import type { JobState } from '../api/jobContract'
|
|
||||||
import { ProgressBar, StateBadge } from './ProgressBar'
|
|
||||||
|
|
||||||
describe('StateBadge', () => {
|
|
||||||
it.each<[JobState, string, string]>([
|
|
||||||
['queued', 'Queued', 'neutral'],
|
|
||||||
['running', 'Running', 'accent'],
|
|
||||||
['waiting_input', 'Needs your input', 'warning'],
|
|
||||||
['succeeded', 'Succeeded', 'success'],
|
|
||||||
['failed', 'Failed', 'danger'],
|
|
||||||
['cancelled', 'Cancelled', 'neutral'],
|
|
||||||
])('%s reads %s in the %s tone', (state, text, tone) => {
|
|
||||||
render(<StateBadge state={state} />)
|
|
||||||
expect(screen.getByText(text)).toHaveClass(`pill-${tone}`)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('ProgressBar', () => {
|
|
||||||
it('is a determinate progressbar with a percentage', () => {
|
|
||||||
render(<ProgressBar fraction={0.256} label="Scan" />)
|
|
||||||
const bar = screen.getByRole('progressbar', { name: 'Scan' })
|
|
||||||
expect(bar).toHaveAttribute('aria-valuenow', '26')
|
|
||||||
expect(bar).toHaveAttribute('aria-valuetext', 'Scan: 26%')
|
|
||||||
expect(bar).toHaveClass('bar-accent')
|
|
||||||
expect(bar).not.toHaveClass('bar-indeterminate', 'bar-small')
|
|
||||||
expect(bar.firstElementChild).toHaveStyle({ width: '26%' })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('is indeterminate without a fraction', () => {
|
|
||||||
render(<ProgressBar fraction={null} label="Scan" tone="warning" small />)
|
|
||||||
const bar = screen.getByRole('progressbar')
|
|
||||||
expect(bar).not.toHaveAttribute('aria-valuenow')
|
|
||||||
expect(bar).toHaveAttribute('aria-valuetext', 'Scan: working')
|
|
||||||
expect(bar).toHaveClass('bar-indeterminate', 'bar-small', 'bar-warning')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clamps out-of-range fractions', () => {
|
|
||||||
const { rerender } = render(<ProgressBar fraction={4} label="x" />)
|
|
||||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100')
|
|
||||||
rerender(<ProgressBar fraction={-1} label="x" />)
|
|
||||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
import type { JobState } from '../api/jobContract'
|
|
||||||
|
|
||||||
const STATE_LABEL: Record<JobState, string> = {
|
|
||||||
queued: 'Queued',
|
|
||||||
running: 'Running',
|
|
||||||
waiting_input: 'Needs your input',
|
|
||||||
succeeded: 'Succeeded',
|
|
||||||
failed: 'Failed',
|
|
||||||
cancelled: 'Cancelled',
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATE_TONE: Record<JobState, string> = {
|
|
||||||
queued: 'neutral',
|
|
||||||
running: 'accent',
|
|
||||||
waiting_input: 'warning',
|
|
||||||
succeeded: 'success',
|
|
||||||
failed: 'danger',
|
|
||||||
cancelled: 'neutral',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateBadge({ state }: { state: JobState }) {
|
|
||||||
return <span className={`pill pill-${STATE_TONE[state]}`}>{STATE_LABEL[state]}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BarProps {
|
|
||||||
/** 0..1, or null for an indeterminate phase. */
|
|
||||||
fraction: number | null
|
|
||||||
label: string
|
|
||||||
tone?: 'accent' | 'success' | 'danger' | 'warning'
|
|
||||||
small?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determinate bar with a real `progressbar` role, or an indeterminate sweep
|
|
||||||
* when the job reported `total: null`. Width transitions use the motion
|
|
||||||
* tokens, so reduced-motion users get instant jumps.
|
|
||||||
*/
|
|
||||||
export function ProgressBar({ fraction, label, tone = 'accent', small = false }: BarProps) {
|
|
||||||
const pct = fraction === null ? null : Math.round(Math.min(1, Math.max(0, fraction)) * 100)
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`bar bar-${tone}${small ? ' bar-small' : ''}${pct === null ? ' bar-indeterminate' : ''}`}
|
|
||||||
role="progressbar"
|
|
||||||
aria-label={label}
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={100}
|
|
||||||
aria-valuenow={pct ?? undefined}
|
|
||||||
aria-valuetext={pct === null ? `${label}: working` : `${label}: ${pct}%`}
|
|
||||||
>
|
|
||||||
<div className="bar-fill" style={pct === null ? undefined : { width: `${pct}%` }} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
import { act, screen, waitFor } from '@testing-library/react'
|
|
||||||
import userEvent from '@testing-library/user-event'
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { apiError, bodiesOf, renderClient, stubApi } from '../test/harness'
|
|
||||||
import { hub, type PromptEvent } from './hub'
|
|
||||||
import { PromptForm } from './PromptForm'
|
|
||||||
|
|
||||||
const prompt = (over: Partial<PromptEvent> = {}): PromptEvent => ({
|
|
||||||
seq: 1, ts: 't', type: 'prompt', prompt_id: 'q1', kind: 'text', message: 'Which one?', ...over,
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('PromptForm', () => {
|
|
||||||
afterEach(() => vi.unstubAllGlobals())
|
|
||||||
|
|
||||||
describe('choice', () => {
|
|
||||||
const options = [
|
|
||||||
{ value: 'a', label: 'Alpha', detail: 'the first' },
|
|
||||||
{ value: 'b', label: 'Beta' },
|
|
||||||
]
|
|
||||||
|
|
||||||
it('is a radio group, first option focused, nothing preselected', () => {
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'choice', options })} />)
|
|
||||||
expect(screen.getByRole('radio', { name: /Alpha/ })).toHaveFocus()
|
|
||||||
expect(screen.getByText('the first')).toBeInTheDocument()
|
|
||||||
expect(screen.getByRole('button', { name: 'Answer' })).toBeDisabled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('sends the chosen value and hides the prompt', async () => {
|
|
||||||
const mock = stubApi({ 'POST /api/jobs/j/answer': undefined })
|
|
||||||
const answered = vi.spyOn(hub, 'answered')
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'choice', options })} />)
|
|
||||||
await userEvent.click(screen.getByRole('radio', { name: /Beta/ }))
|
|
||||||
expect(screen.getByText('Beta').closest('label')).toHaveClass('selected')
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Answer' }))
|
|
||||||
await waitFor(() => expect(answered).toHaveBeenCalledWith('j', 'q1'))
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs/j/answer')).toEqual([{ prompt_id: 'q1', value: 'b' }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('honours a default and tolerates missing options', () => {
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'choice', default: 'a' })} />)
|
|
||||||
expect(screen.getByRole('button', { name: 'Answer' })).toBeEnabled()
|
|
||||||
expect(screen.queryAllByRole('radio')).toHaveLength(0)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('text', () => {
|
|
||||||
it('submits on Enter, even empty', async () => {
|
|
||||||
const mock = stubApi({ 'POST /api/jobs/j/answer': undefined })
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ default: 'x' })} />)
|
|
||||||
expect(screen.getByRole('textbox', { name: 'Which one?' })).toHaveFocus()
|
|
||||||
await userEvent.type(screen.getByRole('textbox'), 'y{Enter}')
|
|
||||||
await waitFor(() => expect(bodiesOf(mock, 'POST /api/jobs/j/answer')).toEqual([{ prompt_id: 'q1', value: 'xy' }]))
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('confirm', () => {
|
|
||||||
it('answers yes or no, yes focused by default', async () => {
|
|
||||||
const mock = stubApi({ 'POST /api/jobs/j/answer': undefined })
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'confirm' })} />)
|
|
||||||
expect(screen.getByRole('button', { name: 'Yes' })).toHaveFocus()
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Yes' }))
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'No' }))
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(bodiesOf(mock, 'POST /api/jobs/j/answer').map((b) => (b as { value: string }).value)).toEqual(['yes', 'no']),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('focuses No when that is the default', () => {
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'confirm', default: 'no' })} />)
|
|
||||||
expect(screen.getByRole('button', { name: 'No' })).toHaveFocus()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('phrase', () => {
|
|
||||||
it('stays disabled until the phrase is typed exactly', async () => {
|
|
||||||
const mock = stubApi({ 'POST /api/jobs/j/answer': undefined })
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'phrase', phrase: 'do it' })} />)
|
|
||||||
const answer = screen.getByRole('button', { name: 'Answer' })
|
|
||||||
await userEvent.type(screen.getByRole('textbox'), 'do i')
|
|
||||||
expect(answer).toBeDisabled()
|
|
||||||
await userEvent.type(screen.getByRole('textbox'), 't')
|
|
||||||
expect(answer).toBeEnabled()
|
|
||||||
await userEvent.click(answer)
|
|
||||||
await waitFor(() => expect(bodiesOf(mock, 'POST /api/jobs/j/answer')).toEqual([{ prompt_id: 'q1', value: 'do it' }]))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('copes with a prompt that lost its phrase', () => {
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'phrase' })} />)
|
|
||||||
expect(screen.getByRole('button', { name: 'Answer' })).toBeEnabled() // '' matches ''
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the server error when the answer is refused', async () => {
|
|
||||||
stubApi({ 'POST /api/jobs/j/answer': apiError(404, 'not_found', 'job is gone') })
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt()} />)
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Answer' }))
|
|
||||||
expect(await screen.findByText('job is gone')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('disables the buttons while sending', async () => {
|
|
||||||
let release: () => void = () => {}
|
|
||||||
stubApi({ 'POST /api/jobs/j/answer': () => new Promise<void>((r) => (release = r)) })
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt({ kind: 'confirm' })} />)
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Yes' }))
|
|
||||||
expect(screen.getByRole('button', { name: 'Yes' })).toBeDisabled()
|
|
||||||
expect(screen.getByRole('button', { name: 'No' })).toBeDisabled()
|
|
||||||
await act(async () => release())
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows Sending… on the answer button', async () => {
|
|
||||||
let release: () => void = () => {}
|
|
||||||
stubApi({ 'POST /api/jobs/j/answer': () => new Promise<void>((r) => (release = r)) })
|
|
||||||
renderClient(<PromptForm jobId="j" prompt={prompt()} />)
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Answer' }))
|
|
||||||
expect(await screen.findByRole('button', { name: 'Sending…' })).toBeDisabled()
|
|
||||||
await act(async () => release())
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
import { useMutation } from '@tanstack/react-query'
|
|
||||||
import { useState, type FormEvent } from 'react'
|
|
||||||
import { api } from '../api/client'
|
|
||||||
import { phraseMatches } from '../commands/catalog'
|
|
||||||
import { PhraseField } from '../commands/PhraseField'
|
|
||||||
import { ErrorNotice } from '../ui/Notice'
|
|
||||||
import { GamePrompt } from './GamePrompt'
|
|
||||||
import { hub, type PromptEvent } from './hub'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Answer a job's prompt. Every kind is a real form: Enter submits, the first
|
|
||||||
* control is focused, and choices are radio buttons (arrow keys move).
|
|
||||||
*/
|
|
||||||
export function PromptForm({ jobId, prompt }: { jobId: string; prompt: PromptEvent }) {
|
|
||||||
if (prompt.kind === 'game') return <GamePrompt key={prompt.prompt_id} jobId={jobId} prompt={prompt} />
|
|
||||||
return <AnswerForm jobId={jobId} prompt={prompt} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnswerForm({ jobId, prompt }: { jobId: string; prompt: PromptEvent }) {
|
|
||||||
const [value, setValue] = useState(prompt.default ?? '')
|
|
||||||
const send = useMutation({
|
|
||||||
mutationFn: (v: string) => api.answer(jobId, { prompt_id: prompt.prompt_id, value: v }),
|
|
||||||
onSuccess: () => hub.answered(jobId, prompt.prompt_id),
|
|
||||||
})
|
|
||||||
|
|
||||||
const submit = (e: FormEvent, v: string = value) => {
|
|
||||||
e.preventDefault()
|
|
||||||
send.mutate(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
const phraseOk = prompt.kind !== 'phrase' || phraseMatches(prompt.phrase ?? '', value)
|
|
||||||
const ready = prompt.kind === 'text' || (prompt.kind === 'choice' ? value !== '' : phraseOk)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form className="prompt" onSubmit={submit} aria-label="Job question">
|
|
||||||
<p className="prompt-msg">{prompt.message}</p>
|
|
||||||
{prompt.kind === 'choice' && (
|
|
||||||
<fieldset className="choices">
|
|
||||||
<legend className="sr-only">{prompt.message}</legend>
|
|
||||||
{prompt.options?.map((o, i) => (
|
|
||||||
<label key={o.value} className={value === o.value ? 'choice selected' : 'choice'}>
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={`prompt-${prompt.prompt_id}`}
|
|
||||||
value={o.value}
|
|
||||||
checked={value === o.value}
|
|
||||||
autoFocus={i === 0}
|
|
||||||
onChange={() => setValue(o.value)}
|
|
||||||
/>
|
|
||||||
<span className="choice-label">{o.label}</span>
|
|
||||||
{o.detail && <span className="choice-detail">{o.detail}</span>}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</fieldset>
|
|
||||||
)}
|
|
||||||
{prompt.kind === 'text' && (
|
|
||||||
<input
|
|
||||||
className="input"
|
|
||||||
aria-label={prompt.message}
|
|
||||||
value={value}
|
|
||||||
autoFocus
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{prompt.kind === 'phrase' && (
|
|
||||||
<PhraseField phrase={prompt.phrase ?? ''} value={value} onChange={setValue} autoFocus />
|
|
||||||
)}
|
|
||||||
{send.error && <ErrorNotice error={send.error} />}
|
|
||||||
<div className="actions">
|
|
||||||
{prompt.kind === 'confirm' ? (
|
|
||||||
<>
|
|
||||||
<button type="button" className="btn btn-primary" autoFocus={prompt.default !== 'no'} disabled={send.isPending} onClick={(e) => submit(e, 'yes')}>
|
|
||||||
Yes
|
|
||||||
</button>
|
|
||||||
<button type="button" className="btn btn-ghost" autoFocus={prompt.default === 'no'} disabled={send.isPending} onClick={(e) => submit(e, 'no')}>
|
|
||||||
No
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button type="submit" className="btn btn-primary" disabled={!ready || send.isPending}>
|
|
||||||
{send.isPending ? 'Sending…' : 'Answer'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -14,7 +14,7 @@ describe('JobHub', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
vi.unstubAllGlobals()
|
vi.unstubAllGlobals()
|
||||||
hub.onTerminal = () => {}
|
hub.onTerminal = () => undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
it('reports an empty job before anything arrives', () => {
|
it('reports an empty job before anything arrives', () => {
|
||||||
|
|||||||
@ -72,7 +72,7 @@ class JobHub {
|
|||||||
private live = new Map<string, LiveJob>()
|
private live = new Map<string, LiveJob>()
|
||||||
private streams = new Map<string, Stream>()
|
private streams = new Map<string, Stream>()
|
||||||
private listeners = new Set<() => void>()
|
private listeners = new Set<() => void>()
|
||||||
onTerminal: (id: string) => void = () => {}
|
onTerminal: (id: string) => void = () => undefined
|
||||||
|
|
||||||
subscribe = (listener: () => void): (() => void) => {
|
subscribe = (listener: () => void): (() => void) => {
|
||||||
this.listeners.add(listener)
|
this.listeners.add(listener)
|
||||||
@ -110,7 +110,9 @@ class JobHub {
|
|||||||
const url = `${ENDPOINTS.jobEvents(id)}?token=${encodeURIComponent(sessionToken())}&after=${live.lastSeq}`
|
const url = `${ENDPOINTS.jobEvents(id)}?token=${encodeURIComponent(sessionToken())}&after=${live.lastSeq}`
|
||||||
const source = new EventSource(url)
|
const source = new EventSource(url)
|
||||||
s.source = source
|
s.source = source
|
||||||
const onEvent = (msg: MessageEvent<string>) => this.ingest(id, msg.data)
|
const onEvent = (msg: MessageEvent<string>) => {
|
||||||
|
this.ingest(id, msg.data)
|
||||||
|
}
|
||||||
source.onmessage = onEvent
|
source.onmessage = onEvent
|
||||||
for (const t of EVENT_TYPES) source.addEventListener(t, onEvent)
|
for (const t of EVENT_TYPES) source.addEventListener(t, onEvent)
|
||||||
source.onopen = () => {
|
source.onopen = () => {
|
||||||
@ -124,7 +126,9 @@ class JobHub {
|
|||||||
const state = this.get(id).state
|
const state = this.get(id).state
|
||||||
if (state && TERMINAL.has(state)) return
|
if (state && TERMINAL.has(state)) return
|
||||||
if (s.refs > 0) {
|
if (s.refs > 0) {
|
||||||
s.retry = setTimeout(() => this.open(id, s), s.backoff)
|
s.retry = setTimeout(() => {
|
||||||
|
this.open(id, s)
|
||||||
|
}, s.backoff)
|
||||||
s.backoff = Math.min(s.backoff * 2, 10_000)
|
s.backoff = Math.min(s.backoff * 2, 10_000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
web/src/jobs/jobProgress.log.test.ts
Normal file
85
web/src/jobs/jobProgress.log.test.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { installEventSource } from '../test/harness'
|
||||||
|
import { mountJob } from '../test/jobHelpers'
|
||||||
|
|
||||||
|
beforeEach(installEventSource)
|
||||||
|
|
||||||
|
const sel = (el: Element, s: string) => el.querySelector(s)
|
||||||
|
|
||||||
|
describe('<job-progress> log', () => {
|
||||||
|
it('lists lines with their time and counts them', () => {
|
||||||
|
const { el, send } = mountJob()
|
||||||
|
send('log', { level: 'info', message: 'hello' })
|
||||||
|
send('log', { level: 'warning', message: 'careful' })
|
||||||
|
expect(sel(el, 'summary')).toHaveTextContent('Log (2 lines)')
|
||||||
|
expect(el.querySelectorAll('.log li')).toHaveLength(2)
|
||||||
|
expect(sel(el, '.log-info')).toHaveTextContent('10:00:00 hello')
|
||||||
|
expect(sel(el, '.log-warning')).toHaveTextContent('careful')
|
||||||
|
expect(sel(el, 'details')).not.toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('only appends new lines, keeping the ones already shown', () => {
|
||||||
|
const { el, send } = mountJob()
|
||||||
|
send('log', { level: 'info', message: 'one' })
|
||||||
|
const first = sel(el, '.log li')
|
||||||
|
send('log', { level: 'info', message: 'two' })
|
||||||
|
expect(sel(el, '.log li')).toBe(first)
|
||||||
|
expect(el.querySelectorAll('.log li')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens by itself on an error line, once, and leaves the toggle to the user', () => {
|
||||||
|
const { el, send } = mountJob()
|
||||||
|
send('log', { level: 'error', message: 'bad' })
|
||||||
|
const details = sel(el, 'details') as HTMLDetailsElement
|
||||||
|
expect(details.open).toBe(true)
|
||||||
|
details.open = false
|
||||||
|
send('log', { level: 'error', message: 'worse' })
|
||||||
|
expect(details.open).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays closed in compact mode', () => {
|
||||||
|
const { el, send } = mountJob({ compact: true })
|
||||||
|
send('log', { level: 'error', message: 'bad' })
|
||||||
|
expect(sel(el, 'details')).not.toHaveAttribute('open')
|
||||||
|
expect(sel(el, 'section')).toHaveClass('job-compact')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing has been printed yet', () => {
|
||||||
|
const { el } = mountJob()
|
||||||
|
expect(sel(el, '.log')).toHaveTextContent('No output yet.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rebuilds the list when the newest-lines window slides', () => {
|
||||||
|
const { el, send } = mountJob()
|
||||||
|
for (let i = 0; i < 502; i++) send('log', { level: 'info', message: `line ${i}` })
|
||||||
|
expect(el.querySelectorAll('.log li')).toHaveLength(500)
|
||||||
|
expect(sel(el, '.log li')).toHaveTextContent('line 2')
|
||||||
|
expect(sel(el, '.log li:last-child')).toHaveTextContent('line 501')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('<job-progress> lifecycle', () => {
|
||||||
|
it('re-renders on a timer, and stops when removed', async () => {
|
||||||
|
vi.useFakeTimers({ now: Date.parse('2026-10-10T10:00:05Z') })
|
||||||
|
const { el, es } = mountJob({ job: { started_at: '2026-10-10T10:00:00Z' } })
|
||||||
|
await vi.advanceTimersByTimeAsync(10)
|
||||||
|
expect(sel(el, '.job-meta')).toHaveTextContent('0:05')
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
expect(sel(el, '.job-meta')).toHaveTextContent('0:08')
|
||||||
|
el.remove()
|
||||||
|
expect(es.closed).toBe(true)
|
||||||
|
await vi.advanceTimersByTimeAsync(3000)
|
||||||
|
expect(sel(el, '.job-meta')).toHaveTextContent('0:08')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('builds a fresh frame and log state when reattached', () => {
|
||||||
|
const { el, send } = mountJob()
|
||||||
|
send('log', { level: 'error', message: 'bad' })
|
||||||
|
const section = sel(el, 'section')
|
||||||
|
el.remove()
|
||||||
|
document.body.append(el)
|
||||||
|
expect(sel(el, 'section')).not.toBe(section)
|
||||||
|
expect(sel(el, 'summary')).toHaveTextContent('Log (1 lines)')
|
||||||
|
expect(sel(el, 'details')).toHaveAttribute('open')
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user