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

Todo: 9b9342ba
This commit is contained in:
Krzysztof Rudnicki 2026-11-09 08:36:38 +01:00
parent 8cc31a2c87
commit cef9fccab8
287 changed files with 15639 additions and 14231 deletions

View File

@ -20,6 +20,9 @@ repos:
args: [--markdown-linebreak-ext=md]
- id: end-of-file-fixer
- 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-toml
- id: check-added-large-files
@ -177,7 +180,7 @@ repos:
hooks:
- id: vitest-coverage
name: vitest with 100% coverage enforcement
entry: npm --prefix web run coverage
entry: pnpm --dir web coverage
language: system
files: ^web/src/
pass_filenames: false

View File

@ -3,7 +3,7 @@
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
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.
## 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
`$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
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
`?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.
- Dev (`npm run dev`): Vite proxies `/api` to `127.0.0.1:8000` (`SBE_PORT`
overrides) with `Host` and `Origin` rewritten to that origin, and injects
the token meta from that port's token file (serve only, never in a build).
- Dev (`pnpm dev`): Astro proxies `/api` to `127.0.0.1:8000` (`SBE_PORT`
overrides) with `Host` and `Origin` rewritten to that origin, and renders
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`,
never returned by any endpoint.

View File

@ -18,26 +18,26 @@ from steam_backlog_enforcer.game_install import _echo
WEB_DIR = (Path(__file__).resolve().parent.parent / "web").resolve()
_BUILT_MARKER = WEB_DIR / "dist" / "index.html"
# Everything whose change invalidates the built bundle. Anything under src,
# plus the build configuration itself - a vite/tsconfig edit changes the
# output even when not one line of src moved.
_SOURCE_DIR = WEB_DIR / "src"
# Everything whose change invalidates the built bundle. Anything under src
# (pages, styles, scripts) or public (copied verbatim into dist), plus the
# build configuration itself - an astro/tsconfig edit or a dependency bump
# changes the output even when not one line of src moved.
_SOURCE_DIRS = (WEB_DIR / "src", WEB_DIR / "public")
_SOURCE_FILES = (
WEB_DIR / "index.html",
WEB_DIR / "astro.config.mjs",
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.app.json",
WEB_DIR / "tsconfig.node.json",
)
_BUILD_TIMEOUT_SECONDS = 600
_NPM_MISSING = (
"npm 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"
"shell's PATH - a systemd unit or cron job will not see it. Either run\n"
"serve from a normal shell, or build once by hand: cd web && npm run build"
_PNPM_MISSING = (
"pnpm was not found on PATH, so the stale web bundle cannot be rebuilt.\n"
"A systemd unit or cron job may not see a pnpm installed per user.\n"
"Either run serve from a normal shell, or build once by hand:\n"
"cd web && pnpm install && pnpm build"
)
@ -59,26 +59,27 @@ def frontend_is_stale() -> bool:
if built == 0.0:
return WEB_DIR.is_dir()
sources = list(_SOURCE_FILES)
if _SOURCE_DIR.is_dir():
sources.extend(path for path in _SOURCE_DIR.rglob("*") if path.is_file())
for source_dir in _SOURCE_DIRS:
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)
def build_frontend() -> bool:
"""Run ``npm run build`` in ``web/``.
"""Run ``pnpm build`` in ``web/``.
Returns:
True if the bundle was rebuilt, False if the build failed (the
captured output is printed either way).
"""
npm = shutil.which("npm")
if npm is None:
_echo(_NPM_MISSING)
pnpm = shutil.which("pnpm")
if pnpm is None:
_echo(_PNPM_MISSING)
return False
_echo("Frontend sources changed - rebuilding web/dist ...", flush=True)
result = subprocess.run(
[npm, "run", "build"],
[pnpm, "build"],
cwd=WEB_DIR,
capture_output=True,
text=True,

View File

@ -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
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
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.
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_PORT = 8000
_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 = (
"This server is running outdated code and has stopped answering rather "
"than report numbers the enforcer is not applying. It restarts on "
@ -159,12 +160,17 @@ class _Handler(BaseHTTPRequestHandler):
return True
def _serve_static(self, path: str) -> None:
"""Serve a file from ``WEB_DIST`` with SPA fallback and traversal guard."""
rel = path.lstrip("/") or _INDEX
candidate = (WEB_DIST / rel).resolve()
# Reject path traversal, then fall back to index.html for SPA routes.
if not candidate.is_relative_to(WEB_DIST) or not candidate.is_file():
candidate = WEB_DIST / _INDEX
"""Serve a file from ``WEB_DIST``: one static page per directory.
``/library`` and ``/library/`` serve ``library/index.html``; anything
unknown (or escaping the bundle) gets ``404.html`` with status 404.
Every ``.html`` page carries this launch's token and is never cached.
"""
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():
send_bytes(self, HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain")
return
@ -174,11 +180,25 @@ class _Handler(BaseHTTPRequestHandler):
ctype = f"{ctype}; charset=utf-8"
body = candidate.read_bytes()
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.
body = _web_auth.inject_token(body, self.server.token)
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.

View File

@ -100,7 +100,7 @@ def _block_real_subprocesses() -> Iterator[None]:
"steam_backlog_enforcer._fast_install.subprocess.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.
patch(
"steam_backlog_enforcer._web_build.subprocess.run",

View File

@ -32,7 +32,7 @@ def web(tmp_path: Path) -> Iterator[Path]:
with (
patch.object(_web_build, "WEB_DIR", root),
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(
_web_build,
"_SOURCE_FILES",
@ -88,6 +88,12 @@ class TestFrontendIsStale:
_touch(web / "src" / "nested" / "app.tsx", 2000.0)
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:
_touch(web / "package.json", 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:
_touch(web / "dist" / "index.html", 1000.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
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:
"""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 (
patch(f"{_PKG}.shutil.which", return_value=None),
patch(f"{_PKG}._echo") as echo,
):
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:
run = MagicMock(return_value=MagicMock(returncode=0))
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}._echo"),
):
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
def test_failed_build_prints_output_and_returns_false(self, web: Path) -> None:
result = MagicMock(returncode=1, stdout=" out \n", stderr=" err \n")
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}._echo") as echo,
):
@ -147,7 +169,7 @@ class TestBuildFrontend:
def test_timeout_propagates(self, web: Path) -> None:
# A hung build must not be swallowed into "bundle is fine".
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",
side_effect=subprocess.TimeoutExpired("npm", 1),

View File

@ -127,7 +127,7 @@ class TestDatasetEndpoint:
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:
dist = _make_dist(tmp_path)
@ -152,19 +152,19 @@ class TestStaticServing:
assert "image/png" in ctype
assert "charset" not in ctype
def test_spa_fallback_to_index(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path)
def test_unknown_route_gets_the_404_page(self, tmp_path: Path) -> None:
dist = _make_dist(tmp_path, files={"404.html": b"<html>MISSING</html>"})
with patch(f"{_PKG}.WEB_DIST", dist), _running() as port:
status, body, _ = _get(port, "/some/spa/route")
assert status == 200
assert b"INDEX" in body
status, body, _ = _get(port, "/some/unknown/route")
assert status == 404
assert b"MISSING" in body
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:
status, body, _ = _get(port, "/../../../../../../etc/passwd")
assert status == 200
assert b"INDEX" in body # fell back to index, did not serve the secret
assert status == 404
assert b"MISSING" in body # got the 404 page, not the secret
assert b"root:" not in body
def test_not_built_returns_404(self, tmp_path: Path) -> None:

View File

@ -40,7 +40,9 @@ def dist(tmp_path: Path) -> Iterator[Path]:
(root / "noext").write_text("plain", "utf-8")
sub = root / "sub"
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")
with patch(f"{_PKG}.WEB_DIST", root):
yield root
@ -93,19 +95,36 @@ class TestStatic:
def test_unknown_extension_is_plain_text(self) -> None:
assert _get("/noext")[2] == "text/plain; charset=utf-8"
def test_a_nested_index_is_served_without_a_token(self) -> None:
_, body, _, cache, _ = _get("/sub/index.html")
assert (body, cache) == (b"<html>SUB</html>", "")
@pytest.mark.parametrize("path", ["/sub", "/sub/", "/sub/index.html", "/sub/?id=7"])
def test_a_directory_serves_its_own_index_with_a_token(self, path: str) -> None:
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:
status, body, _, _, _ = _get("/library/42")
assert (status, b"INDEX" in body) == (200, True)
def test_every_html_file_carries_the_token_and_is_not_cached(self) -> None:
status, body, _, cache, token = _get("/sub/page.html")
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")
assert status == 200
assert (status, b"MISSING" in body) == (404, True)
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:
with patch(f"{_PKG}.WEB_DIST", (tmp_path / "missing").resolve()):
status, body, _, _, _ = _get("/")

4
web/.gitignore vendored
View File

@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Astro generated types
.astro
coverage

View File

@ -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:
- [@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...
},
},
])
```
`pnpm dev` (proxies `/api` to `./run.sh serve`), `pnpm dev:mock` (in-memory API),
`pnpm test`, `pnpm coverage`, `pnpm lint`, `pnpm build`.

33
web/astro.config.mjs Normal file
View 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 } } } },
},
})

View File

@ -1,30 +1,37 @@
import js from '@eslint/js'
import astro from 'eslint-plugin-astro'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'coverage']),
// typescript-eslint strict + stylistic (type-checked) on .ts; .astro files get
// eslint-plugin-astro's recommended set (their scripts also pass astro check).
export default tseslint.config(
{ ignores: ['dist', 'coverage', '.astro', 'src/env.d.ts'] },
{ linterOptions: { reportUnusedDisableDirectives: 'error' } },
js.configs.recommended,
...[...tseslint.configs.strictTypeChecked, ...tseslint.configs.stylisticTypeChecked].map((c) => ({
...c,
files: ['**/*.ts'],
})),
...astro.configs['flat/recommended'],
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
// `recommended` includes the React Compiler rules since plugin v6.
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
files: ['**/*.ts'],
languageOptions: {
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: ['mock/**/*.ts', 'vite.config.ts'],
languageOptions: {
globals: globals.node,
},
files: ['**/*.test.ts', 'src/test/**'],
rules: { '@typescript-eslint/unbound-method': 'off' },
},
])
{
// 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 },
)

View File

@ -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>

View File

@ -1,20 +1,24 @@
// 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
// vite.config.ts keep it out of every production build.
// memory (`pnpm dev:mock`). `apply: 'serve'` plus the mode gate in
// astro.config.mjs keep it out of every production build.
import { randomBytes } from 'node:crypto'
import type { Plugin } from 'vite'
import { TOKEN_META } from '../src/api/contract.ts'
import type { AstroUserConfig } from 'astro'
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 {
// Per-launch token, like the real server's web-<port>.token.
const token = randomBytes(32).toString('hex')
return {
name: 'sbe-mock-api',
apply: 'serve',
transformIndexHtml() {
return [{ tag: 'meta', attrs: { name: TOKEN_META, content: token }, injectTo: 'head' }]
// src/components/DevToken.astro renders this into every page in dev.
config() {
process.env.SBE_DEV_TOKEN = token
},
configureServer(server) {
server.middlewares.use((req, res, next) => {

4556
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,44 +3,39 @@
"private": true,
"version": "0.0.0",
"type": "module",
"packageManager": "pnpm@12.18.0",
"engines": {
"node": ">=24",
"pnpm": ">=10"
},
"scripts": {
"dev": "vite",
"dev:mock": "vite --mode mock",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"dev": "astro dev",
"dev:mock": "astro dev --mode mock",
"build": "astro check && astro build",
"lint": "astro check && eslint src",
"preview": "astro preview",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@tanstack/react-query": "5.107.1",
"@tanstack/react-router": "1.170.46",
"react": "19.3.1",
"react-dom": "19.3.1"
"@kuhyx/web-ui": "github:kuhyx/utils#web_ui-v0.5.0&path:/web_ui",
"astro": "7.4.7"
},
"devDependencies": {
"@babel/core": "8.1.0",
"@astrojs/check": "0.9.12",
"@eslint/js": "10.0.1",
"@rolldown/plugin-babel": "0.3.0",
"@testing-library/dom": "10.4.2",
"@testing-library/jest-dom": "7.0.1",
"@testing-library/react": "16.3.3",
"@testing-library/user-event": "14.6.8",
"@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",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "10.14.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.7",
"eslint-plugin-astro": "3.2.1",
"globals": "17.14.0",
"jsdom": "31.0.0",
"typescript": "6.0.3",
"typescript-eslint": "8.72.1",
"vite": "8.3.6",
"vitest": "5.1.0"
}
}

File diff suppressed because it is too large Load Diff

13
web/pnpm-workspace.yaml Normal file
View 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

View File

@ -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()
})
})

View File

@ -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

View File

@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TOKEN_META } from './contract'
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'
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) {
const m = document.createElement('meta')
m.name = TOKEN_META
@ -15,7 +17,9 @@ function setToken(token: string | null) {
}
describe('sessionToken', () => {
afterEach(() => setToken(null))
afterEach(() => {
setToken(null)
})
it('reads the meta tag, empty when absent', () => {
expect(sessionToken()).toBe('')
setToken('abc')
@ -24,7 +28,9 @@ describe('sessionToken', () => {
})
describe('request', () => {
beforeEach(() => setToken('tok'))
beforeEach(() => {
setToken('tok')
})
afterEach(() => {
setToken(null)
sessionStorage.clear()
@ -76,7 +82,7 @@ describe('request', () => {
const reload = vi.fn()
beforeEach(() => {
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 () => {
@ -107,7 +113,7 @@ describe('api', () => {
afterEach(() => vi.unstubAllGlobals())
it('hits every endpoint with the right verb and path', async () => {
const routes: Record<string, unknown> = {}
const routes: Record<string, Handler> = {}
const keys = [
'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',

View File

@ -103,20 +103,20 @@ export const api = {
saveSetup: (req: SetupRequest) => request<SetupStatus>('POST', ENDPOINTS.setup, req),
daemon: () => get<DaemonStatus>(ENDPOINTS.daemon),
server: () => get<ServerHealth>(ENDPOINTS.server),
restartServer: () => request<void>('POST', ENDPOINTS.serverRestart),
restartServer: () => request<undefined>('POST', ENDPOINTS.serverRestart),
backups: () => get<StateBackup[]>(ENDPOINTS.backups),
restoreBackup: (id: string, phrase: string) =>
request<Job>('POST', ENDPOINTS.backupRestore(encodeURIComponent(id)), { confirm_phrase: phrase }),
jobs: () => get<Job[]>(ENDPOINTS.jobs),
job: (id: string) => get<Job>(ENDPOINTS.job(id)),
answer: (id: string, answer: PromptAnswer) => request<void>('POST', ENDPOINTS.jobAnswer(id), answer),
cancelJob: (id: string) => request<void>('POST', ENDPOINTS.jobCancel(id)),
answer: (id: string, answer: PromptAnswer) => request<undefined>('POST', ENDPOINTS.jobAnswer(id), answer),
cancelJob: (id: string) => request<undefined>('POST', ENDPOINTS.jobCancel(id)),
heartbeat: (id: string) => request<PendingAction>('POST', ENDPOINTS.pendingHeartbeat(id)),
commit: (id: string, phrase: string) =>
request<Job>('POST', ENDPOINTS.pendingCommit(id), { confirm_phrase: phrase }),
/** `keepalive` lets the DELETE survive the page being closed. */
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). */

View File

@ -1,59 +1,138 @@
import { QueryObserver } from '@tanstack/react-query'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makeBudget, makeDataset } from '../test/factories'
import { makeJob } from '../test/fixtures'
import { stubApi } from '../test/harness'
import { isActive, q, queryClient, 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
}
import { apiError, Reply, stubApi, type Handler } from '../test/harness'
import { isActive, q, refreshAfterJob } from './queries'
describe('queries', () => {
afterEach(() => vi.unstubAllGlobals())
beforeEach(() => {
vi.useFakeTimers()
})
it('isActive is false only for terminal states', () => {
expect(isActive(makeJob({ state: 'running' }))).toBe(true)
expect(isActive(makeJob({ state: 'waiting_input' }))).toBe(true)
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', () => {
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 () => {
it('every query reaches its endpoint', async () => {
const mock = stubApi({
'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/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,
q.backups, q.dataset, q.budget, q.jobs, q.job('j1')]
for (const o of all) await queryClient.fetchQuery({ ...o, retry: false, staleTime: 0 })
q.backups, q.dataset, q.budget, q.demoBudget, q.jobs, q.job('j1')]
for (const query of all) await query.refetch()
expect(mock).toHaveBeenCalledTimes(all.length)
expect(all.every((query) => query.state.error === null)).toBe(true)
})
it('refreshAfterJob invalidates everything', async () => {
stubApi({ 'GET /api/server': { stale: false } })
const obs = new QueryObserver(queryClient, { ...q.server, refetchInterval: false })
const unsub = obs.subscribe(() => {})
await vi.waitFor(() => expect(obs.getCurrentResult().isSuccess).toBe(true))
const spy = vi.spyOn(queryClient, 'invalidateQueries')
it('polls jobs fast while one is active, slowly otherwise', async () => {
const jobs = [makeJob({ state: 'running' })]
const mock = stubApi({ 'GET /api/jobs': () => jobs })
q.jobs.subscribe(() => undefined)
await vi.advanceTimersByTimeAsync(0)
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()
expect(spy).toHaveBeenCalled()
unsub()
await vi.advanceTimersByTimeAsync(0)
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)
})
})
})

View File

@ -1,51 +1,33 @@
// TanStack Query bindings. Polling queries set `refetchIntervalInBackground`
// because automation tabs (and a Chrome --app window behind another) report
// hidden, and a paused poll there shows frozen state.
// The app's cached server reads (see lib/query.ts), one per endpoint.
import { queryOptions, QueryClient } from '@tanstack/react-query'
import { fetchBudget, fetchDataset } from '../api'
import { family, invalidateAll, Query } from '../lib/query'
import { api } from './client'
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'])
export const isActive = (j: Job) => !TERMINAL.has(j.state)
export const isActive = (j: Job): boolean => !TERMINAL.has(j.state)
export const q = {
status: queryOptions({ queryKey: ['status'], queryFn: api.status, ...live(15_000) }),
stats: queryOptions({ queryKey: ['stats'], queryFn: api.stats }),
installed: queryOptions({ queryKey: ['installed'], queryFn: api.installed }),
library: queryOptions({ queryKey: ['library'], queryFn: api.library, staleTime: 60_000 }),
commands: queryOptions({ queryKey: ['commands'], queryFn: api.commands, ...live(30_000) }),
setup: queryOptions({ queryKey: ['setup'], queryFn: api.setup }),
daemon: queryOptions({ queryKey: ['daemon'], queryFn: api.daemon, ...live(3_000) }),
server: queryOptions({ queryKey: ['server'], queryFn: api.server, ...live(20_000) }),
backups: queryOptions({ queryKey: ['backups'], queryFn: api.backups }),
dataset: queryOptions({ queryKey: ['dataset'], queryFn: fetchDataset, staleTime: 60_000 }),
budget: queryOptions({ queryKey: ['budget'], queryFn: () => fetchBudget(false), ...live(5_000) }),
jobs: queryOptions({
queryKey: ['jobs'],
queryFn: api.jobs,
refetchInterval: (query) => (query.state.data?.some(isActive) ? 2_000 : 15_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,
}),
status: new Query(api.status, { intervalMs: 15_000 }),
stats: new Query(api.stats),
installed: new Query(api.installed),
library: new Query(api.library, { staleMs: 60_000 }),
commands: new Query(api.commands, { intervalMs: 30_000 }),
setup: new Query(api.setup),
daemon: new Query(api.daemon, { intervalMs: 3_000 }),
server: new Query(api.server, { intervalMs: 20_000 }),
backups: new Query(api.backups),
dataset: new Query(fetchDataset, { staleMs: 60_000 }),
budget: new Query(() => fetchBudget(false), { intervalMs: 5_000 }),
demoBudget: new Query(() => fetchBudget(true), { intervalMs: 5_000 }),
jobs: new Query(api.jobs, { intervalMs: (data) => (data?.some(isActive) ? 2_000 : 15_000) }),
job: family((id) =>
new Query(() => api.job(id), { intervalMs: (data) => (data && !isActive(data) ? false : 2_000) }),
),
}
/** After a job ends, anything it may have changed is stale. */
export function refreshAfterJob(): void {
void queryClient.invalidateQueries()
invalidateAll()
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
})}
</>
)
}

View File

@ -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()
})
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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()
})
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -80,7 +80,7 @@ export function fillPhrase(
const text = template.replace(/\{(\w+)\}/g, (whole, key: string) => {
let value: string | null = null
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) {
value = String(ctx.installed.filter((g) => !g.assigned && !g.protected).length)
}

View 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')
})
})

View 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)

View 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)
})
})

View 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 } }
}

View 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
View 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)

View File

@ -43,5 +43,5 @@ export function toParams(specs: ParamSpec[], values: ParamValues): Record<string
}
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 ?? '')]))
}

View 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(' ')
})
})

View 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)))
}
}
}

View 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()
})
})
})

View 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()
}
}

View 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')
})
})

View 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.'
}
}

View File

@ -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)
}

View 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')
})
})

View 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)
})
})

View 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()
})
})

View 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)

View 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 })
}
}

View File

@ -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))
})
})

View File

@ -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 },
}
}

View 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>

View File

@ -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()
})
})

View File

@ -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&apos;s bar can still move.
</p>
</div>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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(),
)
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View 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>

View 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} />}

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View 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>

View File

@ -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')
})
})

View File

@ -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>
</>
)
}

View File

@ -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)
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
)
}

View 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
View 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
View File

@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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()
})
})
})

View File

@ -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>
)
}

View File

@ -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')
})
})

View File

@ -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>
)
}

View File

@ -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())
})
})

View File

@ -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>
)
}

View File

@ -14,7 +14,7 @@ describe('JobHub', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
hub.onTerminal = () => {}
hub.onTerminal = () => undefined
})
it('reports an empty job before anything arrives', () => {

View File

@ -72,7 +72,7 @@ class JobHub {
private live = new Map<string, LiveJob>()
private streams = new Map<string, Stream>()
private listeners = new Set<() => void>()
onTerminal: (id: string) => void = () => {}
onTerminal: (id: string) => void = () => undefined
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
@ -110,7 +110,9 @@ class JobHub {
const url = `${ENDPOINTS.jobEvents(id)}?token=${encodeURIComponent(sessionToken())}&after=${live.lastSeq}`
const source = new EventSource(url)
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
for (const t of EVENT_TYPES) source.addEventListener(t, onEvent)
source.onopen = () => {
@ -124,7 +126,9 @@ class JobHub {
const state = this.get(id).state
if (state && TERMINAL.has(state)) return
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)
}
}

View 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