From 341bc784acb3922f6c7813a331cf77307ed5af71 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 10 Oct 2026 16:07:42 +0200 Subject: [PATCH] web control UI: all 24 commands in React, ctl.sock daemon, jobs, library browser; earned_time 0.7.0 Co-Authored-By: Claude Opus 5.5 (1M context) --- .pre-commit-config.yaml | 4 +- DOCS-ctl-socket.md | 75 ++ DOCS-web-control-api.md | 206 ++++ TODO-web-control-ui.md | 99 ++ install.sh | 23 + pyproject.toml | 2 +- requirements.txt | 2 +- run.sh | 5 +- scripts/open_ui.sh | 61 + steam-backlog-enforcer.desktop | 9 + steam-backlog-enforcer.service | 13 + steam_backlog_enforcer/_achievement_skip.py | 138 +++ steam_backlog_enforcer/_backups.py | 143 +++ steam_backlog_enforcer/_budget_log_tail.py | 10 +- steam_backlog_enforcer/_cmd_done.py | 49 +- steam_backlog_enforcer/_cmd_done_finalize.py | 11 +- steam_backlog_enforcer/_cmd_playtime.py | 23 +- steam_backlog_enforcer/_command_catalog.py | 141 +++ steam_backlog_enforcer/_command_gate.py | 64 + steam_backlog_enforcer/_command_help.py | 50 + steam_backlog_enforcer/_command_locks.py | 45 + steam_backlog_enforcer/_command_params.py | 149 +++ steam_backlog_enforcer/_config_setup.py | 28 +- steam_backlog_enforcer/_ctl_client.py | 214 ++++ steam_backlog_enforcer/_ctl_context.py | 142 +++ steam_backlog_enforcer/_ctl_control.py | 117 ++ steam_backlog_enforcer/_ctl_gap.py | 89 ++ steam_backlog_enforcer/_ctl_ops.py | 161 +++ steam_backlog_enforcer/_ctl_pending.py | 182 +++ steam_backlog_enforcer/_ctl_protocol.py | 95 ++ steam_backlog_enforcer/_ctl_reset.py | 104 ++ steam_backlog_enforcer/_ctl_restart.py | 109 ++ steam_backlog_enforcer/_ctl_server.py | 204 ++++ steam_backlog_enforcer/_ctl_types.py | 56 + steam_backlog_enforcer/_ctl_wire.py | 89 ++ steam_backlog_enforcer/_echo.py | 39 +- steam_backlog_enforcer/_enforce_guards.py | 68 ++ steam_backlog_enforcer/_enforce_loop.py | 111 +- steam_backlog_enforcer/_friction.py | 68 ++ steam_backlog_enforcer/_gaming_reset.py | 123 ++ steam_backlog_enforcer/_hltb_cached.py | 30 +- steam_backlog_enforcer/_hltb_confidence.py | 8 +- steam_backlog_enforcer/_hltb_detail.py | 11 +- steam_backlog_enforcer/_hltb_search.py | 2 + steam_backlog_enforcer/_hltb_search_api.py | 29 +- steam_backlog_enforcer/_own_pick.py | 131 ++ steam_backlog_enforcer/_owned_apps_cache.py | 74 +- steam_backlog_enforcer/_progress.py | 125 ++ steam_backlog_enforcer/_prompter.py | 168 +++ steam_backlog_enforcer/_scanning_assign.py | 50 +- steam_backlog_enforcer/_scanning_tampering.py | 128 +- steam_backlog_enforcer/_steam_api_client.py | 98 +- steam_backlog_enforcer/_web_art.py | 139 +++ steam_backlog_enforcer/_web_auth.py | 129 ++ steam_backlog_enforcer/_web_daemon.py | 153 +++ steam_backlog_enforcer/_web_errors.py | 105 ++ steam_backlog_enforcer/_web_io.py | 163 +++ steam_backlog_enforcer/_web_jobs.py | 151 +++ steam_backlog_enforcer/_web_library.py | 169 +++ steam_backlog_enforcer/_web_privileged.py | 141 +++ steam_backlog_enforcer/_web_process.py | 116 ++ steam_backlog_enforcer/_web_routes.py | 93 ++ steam_backlog_enforcer/_web_server.py | 260 ++-- steam_backlog_enforcer/_web_setup.py | 91 ++ steam_backlog_enforcer/_web_sse.py | 114 ++ steam_backlog_enforcer/_web_views.py | 131 ++ steam_backlog_enforcer/config.py | 5 + steam_backlog_enforcer/game_uninstall.py | 3 +- steam_backlog_enforcer/jobs/__init__.py | 13 + steam_backlog_enforcer/jobs/__main__.py | 27 + steam_backlog_enforcer/jobs/_control.py | 90 ++ steam_backlog_enforcer/jobs/_daemon_jobs.py | 88 ++ steam_backlog_enforcer/jobs/_errors.py | 48 + steam_backlog_enforcer/jobs/_events.py | 117 ++ steam_backlog_enforcer/jobs/_job_io.py | 143 +++ steam_backlog_enforcer/jobs/_job_prompter.py | 172 +++ steam_backlog_enforcer/jobs/_registry.py | 166 +++ steam_backlog_enforcer/jobs/_runner.py | 215 ++++ steam_backlog_enforcer/jobs/_spawn.py | 147 +++ steam_backlog_enforcer/jobs/_specs.py | 73 ++ steam_backlog_enforcer/jobs/_store.py | 199 ++++ steam_backlog_enforcer/jobs/_view.py | 149 +++ steam_backlog_enforcer/library_hider.py | 10 + steam_backlog_enforcer/main/__init__.py | 79 +- steam_backlog_enforcer/main/_gate.py | 51 + steam_backlog_enforcer/main/_shared.py | 40 +- steam_backlog_enforcer/main/abandon.py | 6 +- steam_backlog_enforcer/main/exception.py | 79 ++ steam_backlog_enforcer/main/install.py | 10 +- steam_backlog_enforcer/main/misc.py | 88 +- steam_backlog_enforcer/main/picks.py | 17 +- steam_backlog_enforcer/protondb.py | 3 +- steam_backlog_enforcer/scanning.py | 10 +- steam_backlog_enforcer/tests/_ctl_fixtures.py | 64 + .../tests/_fake_prompter.py | 78 ++ .../tests/_isolate_control_plane.py | 89 ++ steam_backlog_enforcer/tests/_jobs_helpers.py | 53 + steam_backlog_enforcer/tests/_web_request.py | 28 + steam_backlog_enforcer/tests/conftest.py | 20 +- .../tests/test_achievement_skip.py | 133 +++ steam_backlog_enforcer/tests/test_backups.py | 116 ++ .../tests/test_budget_resolve_reading.py | 2 +- .../tests/test_cmd_done_part2.py | 114 +- .../tests/test_cmd_playtime.py | 10 +- .../tests/test_command_catalog.py | 117 ++ .../tests/test_command_gate.py | 67 ++ .../tests/test_command_params.py | 98 ++ .../tests/test_ctl_client.py | 180 +++ .../tests/test_ctl_context.py | 139 +++ .../tests/test_ctl_control.py | 131 ++ steam_backlog_enforcer/tests/test_ctl_ops.py | 114 ++ .../tests/test_ctl_ops_privileged.py | 174 +++ .../tests/test_ctl_pending.py | 184 +++ .../tests/test_ctl_protocol.py | 174 +++ .../tests/test_ctl_reset_ops.py | 151 +++ .../tests/test_ctl_restart.py | 159 +++ .../tests/test_ctl_server.py | 150 +++ .../tests/test_ctl_server_internals.py | 186 +++ .../tests/test_do_enforce.py | 28 +- .../tests/test_enforce_loop.py | 2 +- .../tests/test_enforce_loop_part2.py | 8 +- .../tests/test_enforce_loop_part3.py | 2 +- .../tests/test_enforce_steps.py | 33 +- .../tests/test_gaming_reset.py | 103 ++ .../tests/test_hltb_progress_cb.py | 25 + .../tests/test_hltb_search_url_memo.py | 46 + .../tests/test_jobs_control.py | 150 +++ .../tests/test_jobs_daemon_jobs.py | 90 ++ .../tests/test_jobs_events.py | 108 ++ .../tests/test_jobs_job_io.py | 160 +++ .../tests/test_jobs_prompter.py | 217 ++++ .../tests/test_jobs_registry.py | 140 +++ .../tests/test_jobs_runner.py | 230 ++++ .../tests/test_jobs_runner_parts.py | 183 +++ .../tests/test_jobs_spawn.py | 203 ++++ .../tests/test_jobs_specs_errors.py | 101 ++ .../tests/test_jobs_store.py | 213 ++++ .../tests/test_jobs_view.py | 224 ++++ .../tests/test_main_abandon.py | 71 +- .../tests/test_main_gate.py | 40 + .../tests/test_main_install.py | 9 +- .../tests/test_main_misc.py | 90 +- .../tests/test_main_misc_gating.py | 52 +- .../tests/test_main_pick_cap.py | 10 +- .../tests/test_main_pick_manual.py | 8 +- .../tests/test_main_pick_manual_cascade.py | 12 +- .../tests/test_main_status_two_picks.py | 60 + steam_backlog_enforcer/tests/test_own_pick.py | 155 +++ .../tests/test_owned_apps_cache.py | 162 +++ steam_backlog_enforcer/tests/test_prompter.py | 140 +++ .../tests/test_scanning_assign_pick.py | 68 ++ ...ached_confidence_overlay_avoids_refetch.py | 10 + .../tests/test_scanning_part2.py | 68 +- .../tests/test_scanning_tampering_window.py | 90 ++ .../tests/test_steam_api_scan_extras.py | 88 ++ steam_backlog_enforcer/tests/test_web_art.py | 172 +++ steam_backlog_enforcer/tests/test_web_auth.py | 94 ++ .../tests/test_web_daemon.py | 159 +++ .../tests/test_web_errors.py | 67 ++ steam_backlog_enforcer/tests/test_web_io.py | 140 +++ steam_backlog_enforcer/tests/test_web_jobs.py | 198 ++++ .../tests/test_web_library.py | 191 +++ .../tests/test_web_privileged.py | 97 ++ .../tests/test_web_process.py | 121 ++ .../tests/test_web_routes.py | 41 + .../tests/test_web_server.py | 17 +- .../tests/test_web_server_routing.py | 223 ++++ .../tests/test_web_server_stale.py | 8 +- .../tests/test_web_server_static.py | 150 +++ .../tests/test_web_setup.py | 160 +++ steam_backlog_enforcer/tests/test_web_sse.py | 209 ++++ .../tests/test_web_views.py | 120 ++ web/eslint.config.js | 8 + web/index.html | 2 +- web/mock/budget.ts | 85 ++ web/mock/catalog.ts | 117 ++ web/mock/games.ts | 96 ++ web/mock/http.ts | 83 ++ web/mock/jobs.ts | 152 +++ web/mock/pending.ts | 62 + web/mock/plugin.ts | 27 + web/mock/routes.ts | 169 +++ web/mock/scripts.ts | 194 +++ web/mock/sse.ts | 33 + web/mock/world.ts | 209 ++++ web/package-lock.json | 1052 +++++++++++++++-- web/package.json | 6 + web/pnpm-lock.yaml | 673 ++++++++--- web/src/App.test.tsx | 197 ++- web/src/App.tsx | 49 +- web/src/api.test.ts | 26 + web/src/api/client.test.ts | 137 +++ web/src/api/client.ts | 126 ++ web/src/api/contract.ts | 210 ++++ web/src/api/errors.test.ts | 21 + web/src/api/errors.ts | 41 + web/src/api/jobContract.ts | 120 ++ web/src/api/queries.test.ts | 59 + web/src/api/queries.ts | 51 + web/src/commands/CommandCard.test.tsx | 94 ++ web/src/commands/CommandCard.tsx | 65 + web/src/commands/CommandPalette.test.tsx | 134 +++ web/src/commands/CommandPalette.tsx | 115 ++ web/src/commands/ParamsForm.test.tsx | 59 + web/src/commands/ParamsForm.tsx | 62 + web/src/commands/PendingCountdown.test.tsx | 182 +++ web/src/commands/PendingCountdown.tsx | 120 ++ web/src/commands/PhraseField.test.tsx | 54 + web/src/commands/PhraseField.tsx | 58 + .../commands/RunnerDialog.countdown.test.tsx | 86 ++ web/src/commands/RunnerDialog.test.tsx | 214 ++++ web/src/commands/RunnerDialog.tsx | 181 +++ web/src/commands/RunnerProvider.test.tsx | 95 ++ web/src/commands/RunnerProvider.tsx | 46 + web/src/commands/catalog.test.ts | 62 + web/src/commands/catalog.ts | 95 ++ web/src/commands/params.test.ts | 67 ++ web/src/commands/params.ts | 45 + web/src/commands/runnerContext.ts | 14 + web/src/commands/useGameLookup.test.tsx | 52 + web/src/commands/useGameLookup.ts | 27 + web/src/components/BudgetPanel.tsx | 4 +- web/src/components/PlannerView.test.tsx | 83 ++ web/src/components/PlannerView.tsx | 4 +- web/src/index.css | 285 ++--- web/src/jobs/GamePrompt.test.tsx | 113 ++ web/src/jobs/GamePrompt.tsx | 49 + web/src/jobs/JobProgress.nolive.test.tsx | 17 + web/src/jobs/JobProgress.test.tsx | 194 +++ web/src/jobs/JobProgress.tsx | 116 ++ web/src/jobs/ProgressBar.test.tsx | 45 + web/src/jobs/ProgressBar.tsx | 53 + web/src/jobs/PromptForm.test.tsx | 118 ++ web/src/jobs/PromptForm.tsx | 87 ++ web/src/jobs/hub.test.ts | 215 ++++ web/src/jobs/hub.ts | 158 +++ web/src/jobs/useLiveJob.test.tsx | 35 + web/src/jobs/useLiveJob.ts | 9 + web/src/library/GameBrowser.lag.test.tsx | 40 + web/src/library/GameBrowser.test.tsx | 247 ++++ web/src/library/GameBrowser.tsx | 135 +++ web/src/library/GameCard.test.tsx | 90 ++ web/src/library/GameCard.tsx | 73 ++ web/src/library/SearchBox.test.tsx | 131 ++ web/src/library/SearchBox.tsx | 104 ++ web/src/library/fuzzy.test.ts | 140 +++ web/src/library/fuzzy.ts | 129 ++ web/src/library/view.test.ts | 73 ++ web/src/library/view.ts | 72 ++ web/src/main.tsx | 5 + web/src/router.tsx | 59 + web/src/screens/Backlog.test.tsx | 63 + web/src/screens/Backlog.tsx | 54 + web/src/screens/Dashboard.test.tsx | 144 +++ web/src/screens/Dashboard.tsx | 100 ++ web/src/screens/Gaming.test.tsx | 34 + web/src/screens/Gaming.tsx | 27 + web/src/screens/Jobs.test.tsx | 71 ++ web/src/screens/Jobs.tsx | 71 ++ web/src/screens/Library.test.tsx | 87 ++ web/src/screens/Library.tsx | 87 ++ web/src/screens/NotFound.tsx | 11 + web/src/screens/Picks.tsx | 47 + web/src/screens/Setup.test.tsx | 110 ++ web/src/screens/Setup.tsx | 101 ++ web/src/screens/Small.test.tsx | 90 ++ web/src/screens/Store.tsx | 26 + web/src/screens/System.test.tsx | 51 + web/src/screens/System.tsx | 36 + web/src/screens/system/BackupsCard.test.tsx | 99 ++ web/src/screens/system/BackupsCard.tsx | 83 ++ web/src/screens/system/DaemonCard.test.tsx | 89 ++ web/src/screens/system/DaemonCard.tsx | 65 + web/src/screens/system/ServerCard.test.tsx | 52 + web/src/screens/system/ServerCard.tsx | 36 + web/src/shell/AppShell.test.tsx | 80 ++ web/src/shell/AppShell.tsx | 85 ++ web/src/shell/Health.test.tsx | 80 ++ web/src/shell/Health.tsx | 40 + web/src/shell/JobIndicator.test.tsx | 62 + web/src/shell/JobIndicator.tsx | 43 + web/src/shell/nav.ts | 20 + web/src/shell/useServerRestart.test.tsx | 34 + web/src/shell/useServerRestart.ts | 23 + web/src/styles/app.css | 186 +++ web/src/styles/controls.css | 127 ++ web/src/styles/library.css | 104 ++ web/src/styles/screens.css | 146 +++ web/src/styles/tokens.css | 151 +++ web/src/test/dialogRoutes.ts | 18 + web/src/test/fixtures.ts | 90 ++ web/src/test/harness.tsx | 143 +++ web/src/test/setup.ts | 21 + web/src/ui/Dialog.test.tsx | 84 ++ web/src/ui/Dialog.tsx | 60 + web/src/ui/Notice.test.tsx | 78 ++ web/src/ui/Notice.tsx | 65 + web/src/ui/Page.test.tsx | 43 + web/src/ui/Page.tsx | 51 + web/src/ui/format.test.ts | 43 + web/src/ui/format.ts | 45 + web/src/ui/useNow.test.tsx | 26 + web/src/ui/useNow.ts | 11 + web/tsconfig.node.json | 2 +- web/vite.config.ts | 120 +- 305 files changed, 27658 insertions(+), 1347 deletions(-) create mode 100644 DOCS-ctl-socket.md create mode 100644 DOCS-web-control-api.md create mode 100644 TODO-web-control-ui.md create mode 100755 scripts/open_ui.sh create mode 100644 steam-backlog-enforcer.desktop create mode 100644 steam_backlog_enforcer/_achievement_skip.py create mode 100644 steam_backlog_enforcer/_backups.py create mode 100644 steam_backlog_enforcer/_command_catalog.py create mode 100644 steam_backlog_enforcer/_command_gate.py create mode 100644 steam_backlog_enforcer/_command_help.py create mode 100644 steam_backlog_enforcer/_command_locks.py create mode 100644 steam_backlog_enforcer/_command_params.py create mode 100644 steam_backlog_enforcer/_ctl_client.py create mode 100644 steam_backlog_enforcer/_ctl_context.py create mode 100644 steam_backlog_enforcer/_ctl_control.py create mode 100644 steam_backlog_enforcer/_ctl_gap.py create mode 100644 steam_backlog_enforcer/_ctl_ops.py create mode 100644 steam_backlog_enforcer/_ctl_pending.py create mode 100644 steam_backlog_enforcer/_ctl_protocol.py create mode 100644 steam_backlog_enforcer/_ctl_reset.py create mode 100644 steam_backlog_enforcer/_ctl_restart.py create mode 100644 steam_backlog_enforcer/_ctl_server.py create mode 100644 steam_backlog_enforcer/_ctl_types.py create mode 100644 steam_backlog_enforcer/_ctl_wire.py create mode 100644 steam_backlog_enforcer/_enforce_guards.py create mode 100644 steam_backlog_enforcer/_friction.py create mode 100644 steam_backlog_enforcer/_gaming_reset.py create mode 100644 steam_backlog_enforcer/_own_pick.py create mode 100644 steam_backlog_enforcer/_progress.py create mode 100644 steam_backlog_enforcer/_prompter.py create mode 100644 steam_backlog_enforcer/_web_art.py create mode 100644 steam_backlog_enforcer/_web_auth.py create mode 100644 steam_backlog_enforcer/_web_daemon.py create mode 100644 steam_backlog_enforcer/_web_errors.py create mode 100644 steam_backlog_enforcer/_web_io.py create mode 100644 steam_backlog_enforcer/_web_jobs.py create mode 100644 steam_backlog_enforcer/_web_library.py create mode 100644 steam_backlog_enforcer/_web_privileged.py create mode 100644 steam_backlog_enforcer/_web_process.py create mode 100644 steam_backlog_enforcer/_web_routes.py create mode 100644 steam_backlog_enforcer/_web_setup.py create mode 100644 steam_backlog_enforcer/_web_sse.py create mode 100644 steam_backlog_enforcer/_web_views.py create mode 100644 steam_backlog_enforcer/jobs/__init__.py create mode 100644 steam_backlog_enforcer/jobs/__main__.py create mode 100644 steam_backlog_enforcer/jobs/_control.py create mode 100644 steam_backlog_enforcer/jobs/_daemon_jobs.py create mode 100644 steam_backlog_enforcer/jobs/_errors.py create mode 100644 steam_backlog_enforcer/jobs/_events.py create mode 100644 steam_backlog_enforcer/jobs/_job_io.py create mode 100644 steam_backlog_enforcer/jobs/_job_prompter.py create mode 100644 steam_backlog_enforcer/jobs/_registry.py create mode 100644 steam_backlog_enforcer/jobs/_runner.py create mode 100644 steam_backlog_enforcer/jobs/_spawn.py create mode 100644 steam_backlog_enforcer/jobs/_specs.py create mode 100644 steam_backlog_enforcer/jobs/_store.py create mode 100644 steam_backlog_enforcer/jobs/_view.py create mode 100644 steam_backlog_enforcer/main/_gate.py create mode 100644 steam_backlog_enforcer/main/exception.py create mode 100644 steam_backlog_enforcer/tests/_ctl_fixtures.py create mode 100644 steam_backlog_enforcer/tests/_fake_prompter.py create mode 100644 steam_backlog_enforcer/tests/_isolate_control_plane.py create mode 100644 steam_backlog_enforcer/tests/_jobs_helpers.py create mode 100644 steam_backlog_enforcer/tests/_web_request.py create mode 100644 steam_backlog_enforcer/tests/test_achievement_skip.py create mode 100644 steam_backlog_enforcer/tests/test_backups.py create mode 100644 steam_backlog_enforcer/tests/test_command_catalog.py create mode 100644 steam_backlog_enforcer/tests/test_command_gate.py create mode 100644 steam_backlog_enforcer/tests/test_command_params.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_client.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_context.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_control.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_ops.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_ops_privileged.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_pending.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_protocol.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_reset_ops.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_restart.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_server.py create mode 100644 steam_backlog_enforcer/tests/test_ctl_server_internals.py create mode 100644 steam_backlog_enforcer/tests/test_gaming_reset.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_progress_cb.py create mode 100644 steam_backlog_enforcer/tests/test_hltb_search_url_memo.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_control.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_daemon_jobs.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_events.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_job_io.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_prompter.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_registry.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_runner.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_runner_parts.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_spawn.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_specs_errors.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_store.py create mode 100644 steam_backlog_enforcer/tests/test_jobs_view.py create mode 100644 steam_backlog_enforcer/tests/test_main_gate.py create mode 100644 steam_backlog_enforcer/tests/test_main_status_two_picks.py create mode 100644 steam_backlog_enforcer/tests/test_own_pick.py create mode 100644 steam_backlog_enforcer/tests/test_owned_apps_cache.py create mode 100644 steam_backlog_enforcer/tests/test_prompter.py create mode 100644 steam_backlog_enforcer/tests/test_scanning_assign_pick.py create mode 100644 steam_backlog_enforcer/tests/test_scanning_tampering_window.py create mode 100644 steam_backlog_enforcer/tests/test_steam_api_scan_extras.py create mode 100644 steam_backlog_enforcer/tests/test_web_art.py create mode 100644 steam_backlog_enforcer/tests/test_web_auth.py create mode 100644 steam_backlog_enforcer/tests/test_web_daemon.py create mode 100644 steam_backlog_enforcer/tests/test_web_errors.py create mode 100644 steam_backlog_enforcer/tests/test_web_io.py create mode 100644 steam_backlog_enforcer/tests/test_web_jobs.py create mode 100644 steam_backlog_enforcer/tests/test_web_library.py create mode 100644 steam_backlog_enforcer/tests/test_web_privileged.py create mode 100644 steam_backlog_enforcer/tests/test_web_process.py create mode 100644 steam_backlog_enforcer/tests/test_web_routes.py create mode 100644 steam_backlog_enforcer/tests/test_web_server_routing.py create mode 100644 steam_backlog_enforcer/tests/test_web_server_static.py create mode 100644 steam_backlog_enforcer/tests/test_web_setup.py create mode 100644 steam_backlog_enforcer/tests/test_web_sse.py create mode 100644 steam_backlog_enforcer/tests/test_web_views.py create mode 100644 web/mock/budget.ts create mode 100644 web/mock/catalog.ts create mode 100644 web/mock/games.ts create mode 100644 web/mock/http.ts create mode 100644 web/mock/jobs.ts create mode 100644 web/mock/pending.ts create mode 100644 web/mock/plugin.ts create mode 100644 web/mock/routes.ts create mode 100644 web/mock/scripts.ts create mode 100644 web/mock/sse.ts create mode 100644 web/mock/world.ts create mode 100644 web/src/api.test.ts create mode 100644 web/src/api/client.test.ts create mode 100644 web/src/api/client.ts create mode 100644 web/src/api/contract.ts create mode 100644 web/src/api/errors.test.ts create mode 100644 web/src/api/errors.ts create mode 100644 web/src/api/jobContract.ts create mode 100644 web/src/api/queries.test.ts create mode 100644 web/src/api/queries.ts create mode 100644 web/src/commands/CommandCard.test.tsx create mode 100644 web/src/commands/CommandCard.tsx create mode 100644 web/src/commands/CommandPalette.test.tsx create mode 100644 web/src/commands/CommandPalette.tsx create mode 100644 web/src/commands/ParamsForm.test.tsx create mode 100644 web/src/commands/ParamsForm.tsx create mode 100644 web/src/commands/PendingCountdown.test.tsx create mode 100644 web/src/commands/PendingCountdown.tsx create mode 100644 web/src/commands/PhraseField.test.tsx create mode 100644 web/src/commands/PhraseField.tsx create mode 100644 web/src/commands/RunnerDialog.countdown.test.tsx create mode 100644 web/src/commands/RunnerDialog.test.tsx create mode 100644 web/src/commands/RunnerDialog.tsx create mode 100644 web/src/commands/RunnerProvider.test.tsx create mode 100644 web/src/commands/RunnerProvider.tsx create mode 100644 web/src/commands/catalog.test.ts create mode 100644 web/src/commands/catalog.ts create mode 100644 web/src/commands/params.test.ts create mode 100644 web/src/commands/params.ts create mode 100644 web/src/commands/runnerContext.ts create mode 100644 web/src/commands/useGameLookup.test.tsx create mode 100644 web/src/commands/useGameLookup.ts create mode 100644 web/src/components/PlannerView.test.tsx create mode 100644 web/src/jobs/GamePrompt.test.tsx create mode 100644 web/src/jobs/GamePrompt.tsx create mode 100644 web/src/jobs/JobProgress.nolive.test.tsx create mode 100644 web/src/jobs/JobProgress.test.tsx create mode 100644 web/src/jobs/JobProgress.tsx create mode 100644 web/src/jobs/ProgressBar.test.tsx create mode 100644 web/src/jobs/ProgressBar.tsx create mode 100644 web/src/jobs/PromptForm.test.tsx create mode 100644 web/src/jobs/PromptForm.tsx create mode 100644 web/src/jobs/hub.test.ts create mode 100644 web/src/jobs/hub.ts create mode 100644 web/src/jobs/useLiveJob.test.tsx create mode 100644 web/src/jobs/useLiveJob.ts create mode 100644 web/src/library/GameBrowser.lag.test.tsx create mode 100644 web/src/library/GameBrowser.test.tsx create mode 100644 web/src/library/GameBrowser.tsx create mode 100644 web/src/library/GameCard.test.tsx create mode 100644 web/src/library/GameCard.tsx create mode 100644 web/src/library/SearchBox.test.tsx create mode 100644 web/src/library/SearchBox.tsx create mode 100644 web/src/library/fuzzy.test.ts create mode 100644 web/src/library/fuzzy.ts create mode 100644 web/src/library/view.test.ts create mode 100644 web/src/library/view.ts create mode 100644 web/src/router.tsx create mode 100644 web/src/screens/Backlog.test.tsx create mode 100644 web/src/screens/Backlog.tsx create mode 100644 web/src/screens/Dashboard.test.tsx create mode 100644 web/src/screens/Dashboard.tsx create mode 100644 web/src/screens/Gaming.test.tsx create mode 100644 web/src/screens/Gaming.tsx create mode 100644 web/src/screens/Jobs.test.tsx create mode 100644 web/src/screens/Jobs.tsx create mode 100644 web/src/screens/Library.test.tsx create mode 100644 web/src/screens/Library.tsx create mode 100644 web/src/screens/NotFound.tsx create mode 100644 web/src/screens/Picks.tsx create mode 100644 web/src/screens/Setup.test.tsx create mode 100644 web/src/screens/Setup.tsx create mode 100644 web/src/screens/Small.test.tsx create mode 100644 web/src/screens/Store.tsx create mode 100644 web/src/screens/System.test.tsx create mode 100644 web/src/screens/System.tsx create mode 100644 web/src/screens/system/BackupsCard.test.tsx create mode 100644 web/src/screens/system/BackupsCard.tsx create mode 100644 web/src/screens/system/DaemonCard.test.tsx create mode 100644 web/src/screens/system/DaemonCard.tsx create mode 100644 web/src/screens/system/ServerCard.test.tsx create mode 100644 web/src/screens/system/ServerCard.tsx create mode 100644 web/src/shell/AppShell.test.tsx create mode 100644 web/src/shell/AppShell.tsx create mode 100644 web/src/shell/Health.test.tsx create mode 100644 web/src/shell/Health.tsx create mode 100644 web/src/shell/JobIndicator.test.tsx create mode 100644 web/src/shell/JobIndicator.tsx create mode 100644 web/src/shell/nav.ts create mode 100644 web/src/shell/useServerRestart.test.tsx create mode 100644 web/src/shell/useServerRestart.ts create mode 100644 web/src/styles/app.css create mode 100644 web/src/styles/controls.css create mode 100644 web/src/styles/library.css create mode 100644 web/src/styles/screens.css create mode 100644 web/src/styles/tokens.css create mode 100644 web/src/test/dialogRoutes.ts create mode 100644 web/src/test/fixtures.ts create mode 100644 web/src/test/harness.tsx create mode 100644 web/src/ui/Dialog.test.tsx create mode 100644 web/src/ui/Dialog.tsx create mode 100644 web/src/ui/Notice.test.tsx create mode 100644 web/src/ui/Notice.tsx create mode 100644 web/src/ui/Page.test.tsx create mode 100644 web/src/ui/Page.tsx create mode 100644 web/src/ui/format.test.ts create mode 100644 web/src/ui/format.ts create mode 100644 web/src/ui/useNow.test.tsx create mode 100644 web/src/ui/useNow.ts diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5066755..8b05a2c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -110,7 +110,7 @@ repos: - --disable-error-code=list-item additional_dependencies: - types-requests - - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time + - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time # =========================================================================== # PYLINT @@ -130,7 +130,7 @@ repos: # (steam_backlog_enforcer._mcp_server). additional_dependencies: - aiohttp==3.14.4 - - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time + - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time - howlongtobeatpy==1.0.24 - mcp==2.3.0 - pytest diff --git a/DOCS-ctl-socket.md b/DOCS-ctl-socket.md new file mode 100644 index 0000000..84331f1 --- /dev/null +++ b/DOCS-ctl-socket.md @@ -0,0 +1,75 @@ +# Control socket: implementation notes + +How the root daemon's `ctl.sock` (contract: `DOCS-web-control-api.md`, "Control +socket") is built. Code: `steam_backlog_enforcer/_ctl_*.py`; client for the web +server: `_ctl_client.py` (results in `_ctl_types.py`, errors in `_ctl_protocol.py`). + +## Trust + +- Root (uid 0) is admitted besides the desktop user: root can already do all of + this directly, so refusing it protects nothing and it keeps recovery/tests on + one interface. Any other uid is closed unanswered (client: `DaemonUnreachableError`). +- A second `enforce` (manual run, `--demo`) never takes the socket from a live + daemon; `--demo` never serves. +- The daemon re-checks the CLI's locks with the same tables + (`_command_locks.py`): under a total block only `gaming_unblock` works; under + the manual-pick lock `store_unblock` and `block_gaming` are `locked`. +- Ops run on connection threads and take the enforce loop's `tick_lock`, so they + never interleave with a pass; the loop reloads state under that lock. + +## Differences from the contract + +- Daemon-only error codes: `op_failed` (the privileged action ran and failed, or + an unexpected exception) and `unsupported` (restart asked of a daemon systemd + does not supervise: the exit would be a stop). Not in `ApiErrorCode`; the web + server should map unknown daemon codes to a 502. +- The two-phase reply uses `pending_id` (the contract's `PendingAction.id`) and an + extra `lapse_after_seconds`; the server maps it. +- `block_gaming` is capped at 1..365 days and refused while a block is active. +- `gaming_reset_arm` while one is armed returns the existing pending action + (same `pending_id`, same `ready_at`): re-arming can neither shorten nor extend + the wait. Pending resets live in daemon memory, so a daemon restart drops them + (fail closed: arm again). +- Test-only: `SBE_CTL_TEST_COUNTDOWN_SECONDS` (1..299) in the unit's environment + shortens the countdown for VM runs. It cannot lengthen it; unset means 300 s. + +## Gaming reset + +`_gaming_reset.reset_today`, shared with the CLI: zeroes billed time and per-game +attribution, re-arms warnings, releases the mounts, keeps earned time (`carry`, +`budget_seconds`; the old CLI path dropped both), and writes a `manual_adjustment` +record to `/var/log/steam-backlog-enforcer/budget.jsonl` (billed seconds and +per-game breakdown before). That record is the auditable trail; the per-day +history mirrors current billed time, so it shows the day as quiet. + +## What a restart does and does not lose + +`restart` sets a flag; the enforce loop finishes its pass, runs one more +accounting tick (billing the time since the last tick), leaves an exit-time +marker (`_ctl_gap.py`), and exits 0; `Restart=always` brings it back after +`RestartSec=5`. The new daemon spends the marker on its first tick, billing the +whole downtime. Everything else already lives on disk after every tick: + +| State | Survives? | Why | +|---|---|---| +| Billed seconds, per-game attribution | yes | `playtime_state.json` saved every tick; the final tick bills up to the exit, the first tick of the new daemon bills the gap | +| Cutoff / kill-grace timing | yes | `blocked_at` is persisted; `_sustain_block` measures from it | +| Playtime mounts (the block itself) | yes | kernel mount table; `reconcile` is mountinfo-driven. `RuntimeDirectoryPreserve=yes` keeps the stub file | +| Fired warnings, carry ledger | yes | in `PlaytimeState` | +| Open store window | yes (blip) | deadline in `state.json`; startup re-blocks the store once, the first tick re-opens it | +| Total block | yes | lock file, guard-lib | +| Armed gaming reset | no | memory only: arm again (fails closed) | +| Audit-journal "last verdict", history throttle | no | cosmetic: one extra `verdict_change` line, one extra history write | + +Why the gap is billed explicitly: the budget clamps each tick's delta to +`2 x ENFORCE_INTERVAL` = 6 s so a suspend cannot inflate it, which would forgive +the restart gap (`RestartSec` + interpreter start + startup pass, seconds to tens +of seconds) and make a restart repeatable free play. The marker is deleted +before use, expires after 300 s and only a requested restart writes one, so a +crash or a long outage still goes through the normal clamp. Downtime nothing +enforces: an unauthorised game started in the gap is killed on the first tick; +the budget cutoff and its mounts persist throughout. + +Rate limit: 1 per 10 minutes, persisted in +`/var/lib/steam-backlog-enforcer/ctl_restart.json` (root, 0600) so restarting +the daemon does not reset it. diff --git a/DOCS-web-control-api.md b/DOCS-web-control-api.md new file mode 100644 index 0000000..859b644 --- /dev/null +++ b/DOCS-web-control-api.md @@ -0,0 +1,206 @@ +# Web control API (contract) + +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 +mirror is `web/src/api/contract.ts`; change both together or neither. + +## Trust model + +- The web server is an unprivileged **user** unit on `127.0.0.1:8000`. +- Privileged work goes to the **root daemon** over `/run/steam-backlog-enforcer/ctl.sock` + (see "Control socket"). The web server never gets root. +- **Friction lives at the trust boundary, never only in the browser.** Typed + phrases, countdowns, the unblock cap and the 24h exception lock are checked + by the server (unprivileged ops) or the daemon (privileged ops). The test: + if `curl` can do it without the UI, it is not gated. +- **There is no way to stop or disable the enforcer** — not in the UI, not + in the CLI, not on the socket. Restart is allowed (it comes back anyway, + `Restart=always`), rate-limited, and only after the daemon flushed state. + +## Web server auth + +- `Host` must be `127.0.0.1:` or `localhost:` (DNS rebinding). +- Every non-GET request must carry `Origin` equal to the served origin. +- A per-launch token (32 random bytes, hex) is written to + `$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-.token` (mode 0600; one + file per port, so a second server never clobbers the live one's) and + injected into `index.html` as ``. + Mutating requests send it as `X-SBE-Token`; `EventSource` sends it as + `?token=`. Missing or wrong token → `403 bad_token`; the UI then reloads + 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). +- Secrets (`steam_api_key`) are write-only: accepted by `POST /api/setup`, + never returned by any endpoint. + +## Endpoints + +| Method | Path | Body / query | Returns | +|---|---|---|---| +| GET | `/api/dataset` | – | `WebDataset` (exists) | +| GET | `/api/budget` | `?demo=1` | `BudgetSnapshot` (exists) | +| GET | `/api/status` | – | `StatusPayload` (`status_payload()`) | +| GET | `/api/stats` | – | `StatsPayload` (as MCP `get_stats`) | +| GET | `/api/installed` | – | `InstalledPayload` | +| GET | `/api/library` | – | `LibraryPayload` (every owned game, see below) | +| GET | `/api/art/{app_id}` | – | `image/jpeg` portrait cover, or `404 not_found` | +| GET | `/api/commands` | – | `CommandSpec[]` — server-driven catalog | +| GET | `/api/setup` | – | `SetupStatus` (configured flags, no secrets) | +| POST | `/api/setup` | `SetupRequest` | `SetupStatus` (validated live against Steam) | +| GET | `/api/daemon` | – | `DaemonStatus` (state, uptime, journal tail) | +| GET | `/api/server` | – | `ServerHealth` (stale?, started_at, version) | +| POST | `/api/server/restart` | – | `202`; the user unit restarts the process | +| GET | `/api/backups` | – | `StateBackup[]` | +| POST | `/api/backups/{id}/restore` | `{confirm_phrase}` | `Job` | +| GET | `/api/jobs` | – | `Job[]` newest first, last 50 | +| POST | `/api/jobs` | `JobRequest` (`confirm_phrase` for every friction command, countdown ones included) | `Job` (`201`) or `PendingAction` (`202`) | +| GET | `/api/jobs/{id}` | – | `Job` | +| GET | `/api/jobs/{id}/events` | `?token=&after=` | SSE stream of `JobEvent` | +| POST | `/api/jobs/{id}/answer` | `PromptAnswer` | `204` | +| POST | `/api/jobs/{id}/cancel` | – | `204` (only if `CommandSpec.cancellable`) | +| POST | `/api/pending/{id}/heartbeat` | – | `PendingAction` | +| POST | `/api/pending/{id}/commit` | `{confirm_phrase}` | `Job` | +| DELETE | `/api/pending/{id}` | – | `204` | + +Errors are `{"error": "", "message": ""}` with a 4xx/5xx +status. Codes: `bad_token`, `bad_origin`, `bad_host`, `unknown_command`, +`invalid_params`, `not_found` (404: unknown job, backup or endpoint), +`wrong_phrase`, `locked` (manual-pick / total-block lock), +`busy` (another mutating job is running), `countdown_running`, +`pending_lapsed` (also for an unknown pending id: the daemon cannot tell +one from a lapsed one), `daemon_unreachable`, `rate_limited`, +`not_cancellable`, `op_failed` (502: a daemon op raised, or a job process +could not be started — that job is then already `failed`), `server_stale` +(503 on every `/api/*` but `/api/server` while the server retires on +outdated code), `unsupported`. A locked command is refused as `locked` even +while another job is running (the lock is checked before `busy`). +`block-gaming` days are capped at 1–365 by the daemon. + +### Library (`/api/library`, `/api/art/{app_id}`) + +`LibraryPayload` is `{games: LibraryGame[]}`, one row per owned game (the +owned-games records joined with the achievement snapshot, so games without +achievements are listed too), in no particular order — the UI sorts: + +| Field | Meaning | +|---|---| +| `app_id`, `name` | Steam app id; name from the owned records, else the snapshot | +| `achievements_total`, `achievements_unlocked` | `0`/`0` when the scan has no data for it | +| `hltb_hours` | completionist hours (HLTB cache, else snapshot), `null` when unknown | +| `playtime_minutes` | total playtime | +| `last_played` | Unix seconds of the last session, `null` when never played | +| `installed` | an `appmanifest` exists in a Steam library folder | +| `assigned` | the assignment or an active manual pick | +| `ineligible_reason` | why "pick my own game" refuses it, `null` when pickable: `No achievements` / `Not scanned yet` (not in the snapshot; the owned record's `has_stats` tells which), `No achievement data` (not in the snapshot and the cached records predate `has_stats`), `Already 100% complete` (100 % or in `finished_app_ids`), `Skipped until ` / `Skipped for now`. Same rule (`_own_pick.ineligible_reason`) the job enforces on the answer | + +The owned records (`games` in `owned_app_ids_cache.json`) are stored by +every owned-list refresh and read at any age. Reads are local-only, except +once: when no refresh has stored records for this account yet, the view asks +Steam (`GetOwnedGames`) a single time and caches the result. + +`GET /api/art/{app_id}` serves a portrait cover, first source wins: + +1. Steam's librarycache, `~/.local/share/Steam/appcache/librarycache//` + (`library_600x900.jpg` / `library_capsule.jpg`, top level or in a hash + subdir; never the 32×32 icons or the landscape `header.jpg`). +2. This server's disk cache, `~/.cache/steam_backlog_enforcer/art/.jpg`. +3. Steam's CDN, `https://shared.steamstatic.com/store_item_assets/steam/apps//library_600x900.jpg`, + stored atomically into (2). + +It is the only view that may use the network, so it is fenced: owned app +ids only (anything else is `404`; the id is an int in a fixed URL, never a +proxy), at most 6 CDN fetches at a time, `(3.05, 6)` s timeout, `image/*` +bodies ≤ 2 MiB only. A failed fetch leaves `.miss` (retry-after time): +a CDN `404`/`403` or oversized body for a week, a network error or other +status for 10 minutes (the total block lists `steamstatic.com`, so art +appears once it lifts). No cover → `404 not_found`, and the UI draws a title +tile. Hits carry `Cache-Control: private, max-age=86400`. Like every GET it +needs no token, so a plain `` works. + +## Jobs + +Every command except pure views runs as a **job**: a subprocess +`python -m steam_backlog_enforcer.jobs run ` that goes through the +same lock gate as the CLI (`_enforce_total_block_lock`, +`_enforce_manual_pick_lock`), then calls the command's non-interactive core +with explicit params, a `Progress` reporter and a `Prompter`. + +- Job dir: `~/.local/state/steam-backlog-enforcer/jobs//` holding + `request.json`, `events.jsonl` (append-only, one `JobEvent` per line), + `answers.jsonl` (written by the server from `POST …/answer`) and + `result.json`. The server only tails files, so jobs survive both page + reloads and server restarts (including `_serve_stale` retirement). +- At most **one mutating job** at a time (`busy` otherwise). Read-only jobs + (`check`, `stats`, `list`, …) may run alongside. +- History: last 50 job dirs kept, older ones pruned at job start. Job ids + are `Z-<6 hex>`, so sorting by id is + creation order even within one second. +- A job whose process cannot be started (e.g. `systemd-run --scope` failing) + is marked `failed` at once, with the launcher's output in its log, and + `POST /api/jobs` answers `op_failed`. + +### JobEvent (one JSON object per line) + +```jsonc +{ "seq": 7, "ts": "2026-10-10T12:00:00Z", "type": "progress", + "step": 3, "total": 12, "label": "Fetching HLTB times", "item": "Hades", + "eta_seconds": 41 } +``` + +`type` is one of: + +- `state` — `{state}` job lifecycle transitions (`JobState`). +- `log` — `{level, message}` the human-readable line the CLI would print. +- `progress` — `{step, total, label, item?, eta_seconds?}`; `total` may be + `null` for indeterminate phases. Any operation over 2 s must emit + determinate progress. +- `prompt` — `{prompt_id, kind, message, options?, phrase?, default?, + app_ids?}`; `kind` is `choice` (pick one of `options`), `confirm` + (yes/no), `phrase` (type `phrase` exactly), `text` or `game`. The job + blocks until the answer arrives. Phrase answers are re-checked by the job, + not the UI. + - `game` ("Pick my own game…" in `pick`/`check`/`done`): the UI shows the + library browser in pick mode; `app_ids` (sorted) are the only games the + job accepts — the event carries ids only, the rows come from + `/api/library`. Answer the app id as a decimal string, or `""` to go back + to the `choice` prompt that offered it. The job re-validates: any other + answer logs a `warning` and re-asks with a new `prompt_id`. The CLI keeps + its text search for the same step. +- `result` — `{ok, summary, data?}` exactly once at the end. + +## Friction (server/daemon-enforced) + +| Command | Phrase (exact) | Extra | +|---|---|---| +| `gaming-reset` | `reset today's gaming budget` | daemon two-phase: arm (phrase required: `POST /api/jobs` with `confirm_phrase`) → 300 s with heartbeats every ≤10 s → commit (phrase required again: `POST /api/pending/{id}/commit`); lapses if a heartbeat is missed by >15 s; logged to the ledger | +| `block-gaming` | `block all gaming for {days} days` | daemon; no undo | +| `unblock` / `buy-dlc` | `unblock the store for {minutes} minutes` | daemon; 1–30 min cap | +| `gaming-unblock` | `force release playtime mounts` | daemon; if unreachable the UI shows the `sudo ./run.sh gaming-unblock` fallback | +| `abandon-pick` | `abandon {game_name}` | – | +| `pick-manual` | `lock in {game_name}` | – | +| `add-exception` | `request exception for {game_name}` | existing 24h lock | +| `uninstall` | `uninstall {count} games` | – | +| `reset` | `wipe all enforcer state` | automatic timestamped backup first | +| backup restore | `restore backup {id}` | – | +| `enforce` restart | – | rate limit: 1 per 10 min, state flushed first | + +`CommandSpec.friction` carries the phrase template so the UI never +hard-codes it. + +## Control socket (root daemon) + +- Path `/run/steam-backlog-enforcer/ctl.sock`, created by the root daemon, + mode `0660`, group = the desktop user's primary group. +- Every connection is checked with `SO_PEERCRED`: uid must equal the + configured desktop user's uid, else the connection is closed unanswered. +- Protocol: one JSON request line → one JSON response line, then close. + Request `{"op": "", "args": {…}}`, response `{"ok": true, "data": …}` + or `{"ok": false, "error": "", "message": "…"}`. +- Allowlisted ops only: `ping`, `status`, `journal_tail{lines}`, + `store_unblock{minutes, phrase}`, `block_gaming{days, phrase}`, + `gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`, + `gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`, + `gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`. diff --git a/TODO-web-control-ui.md b/TODO-web-control-ui.md new file mode 100644 index 0000000..ffdda91 --- /dev/null +++ b/TODO-web-control-ui.md @@ -0,0 +1,99 @@ +# Move every run.sh command into the React web UI + +REMOVE ME AFTER FINISH + +Spec agreed with the user on 2026-10-10 (grilling rounds in session +`session_01M3QPamsDnLQyQ6g4rresQf`). Contract: `DOCS-web-control-api.md` + +`web/src/api/contract.ts`. + +## what +A production-quality React/TS UI that covers ALL 24 CLI commands (views, +jobs with live progress, setup wizard, daemon + server health), so nobody +has to type `./run.sh `. The CLI stays as backend/recovery path; bare +`./run.sh` opens the UI. + +## must +- Privileged ops go through the root daemon's `ctl.sock` (SO_PEERCRED); + the web server never gets root. +- Friction enforced server/daemon-side (phrases, gaming-reset 300 s + heartbeat countdown, unblock cap, 24h exception lock). More friction, not + less. +- must not: any way to stop/disable the enforcer (UI, CLI or socket). + Restart only, rate-limited, after a state flush. +- Long ops: profile on scratch state, fix top hotspot each, determinate + progress (step N/M, item, ETA) for anything >2 s. +- Stack: React 19 + React Compiler + TanStack Router/Query, newest stable, + exact pins; tokens from `~/src/utils/unified-design-system`; keyboard + operability per its `DOCS-operability.md`. +- `.desktop` entry opening a Chrome `--app` window (never port 9222). + +## done +- Every command has a working UI control, driven in headless chromium + against a scratch state dir, with a screenshot each; one live pass of + status + done. +- Before/after timing table for long ops, none slower. +- vitest 100 % on `web/src`, pytest 100 % on new Python modules, + pre-commit + dependency-freshness green. +- Daemon socket: passed in vmbox, then deployed (`sudo ./install.sh`, + daemon restarted) and verified live. + +## status +- [x] contract frozen +- [x] A: Python job runner + Progress/Prompter refactor of input() sites + (applied to main tree, uncommitted; CLI "Type YES" → friction phrases) +- [x] B: root daemon ctl.sock + two-phase privileged ops — passed in vmbox, + NOT deployed to host yet (needs `sudo ./install.sh` + daemon restart) +- [x] C: frontend shell + all screens against mocked backend (landed; + contract split into contract.ts + jobContract.ts for the 250 cap; + `npm run dev:mock` runs it against web/mock) +- [x] D: server write endpoints, auth, SSE, views, catalog; wire A+B +- [x] D2: integration fixes (arm phrase, not_found/server_stale, dev proxy, + token reload, per-port token, job ordering/locked-vs-busy/spawn fail, + save_credentials keeps settings) +- [x] user decided (Q16): `reset` never unblocks the store, CLI or web — + `release_store` flag and web `_reset` wrapper removed; verified on + scratch HOME (tests in test_main_misc* will need updating) +- [x] E: profile + optimise long ops — check ~4x (tampering re-fetch 20 in + flight + early stop), scan −23% after the no-achievements skip cache + warms (`_achievement_skip.py`), scan cancel 71 s → 1 s, stats −1.7 s + (HLTB search-URL memo). Coordinator fix: skip cache only records + Steam's explicit 400 "no stats", never timeouts/5xx (verified). +- [x] user request: "Pick my own game…" in list/done prompts (`_own_pick.py`), + cancel any job waiting on a prompt (cancel.request marker) — verified +- [x] G: VERIFIED 2026-10-10 — scratch HOME (grid 1369, "wicher" → + Witcher 1/2/3, scroll 0 dropped frames p95 16.8 ms, filters, pick-mode + re-ask/back/cancel, mock mode) + vmbox guest `sbe-g` (own-pick → + Witcher 2 assigned in a real pick job, API + browser). Screenshots + /tmp/sbe-g/. Pick submits only via the "Pick …" button (no + double-click assign). Owned-cache `has_stats` + records survive only + once the daemon runs G code (H deploy). Awaiting user confirmation. +- [x] G (spec): Steam-like library browser (user spec Q21–Q26, 2026-10-10, all as + recommended): new Library page grid of ALL owned games with cover art, + same component replaces the own-pick text prompt (new prompt kind + `game`; CLI keeps text search); ineligible games greyed with reason; + art = Steam librarycache (`~/.local/share/Steam/appcache/librarycache/ + //library_capsule.jpg`, 558 games) → server-side CDN fetch + + disk cache (`shared.cloudflare.steamstatic.com/store_item_assets/ + steam/apps//library_600x900.jpg`, not store-blocked) → title tile; + lazy images; fuzzy typo-tolerant search + top-8 suggestion dropdown, + Enter/arrows, roman↔arabic numerals; card: cover, title, ach %, HLTB h, + playtime, installed/assigned badges; sort name/recent/completion/HLTB; + filters hide-completed/installed/pickable. BUILT 2026-10-10 (uncommitted): + `_web_library.py`, `_web_art.py`, owned-cache `games` records, + `ineligible_reason` + `GamePicker`/`JobPrompter.game`, web/src/library/*, + jobs/GamePrompt.tsx, Library.tsx toggle, styles/library.css. Scratch + verification + mock + DOCS update delegated; own-pick end-to-end runs + in a vmbox copy of the enforcer (user, 2026-10-10: never live). DONE: grid lists all ~1369 + owned games with smooth scroll, "wicher" → Witcher 1/2/3 in suggestions, + own-pick from the browser in a live Check-now job assigns the game. +- [ ] H: deploy DONE 2026-10-10 15:23 (`sudo install.sh` + daemon restart, + ctl.sock live; gaming-reset arm → heartbeat → cancel verified live, + 204/410). STILL TODO: every-screen screenshot pass (real server, + scratch HOME) + one live status + done pass. +- [x] tests: pytest 2707 pass, 100 % line+branch; vitest 523 pass, 100 % + (React Compiler off under vitest only: its memo-cache branches are + unreachable for coverage) +- [x] F: run.sh bare → UI (`scripts/open_ui.sh`), .desktop entry (install.sh + `install_desktop_entry`) — opened live, WM class verified +- [ ] F2: live verification of the finished UI +- [x] user confirmation → tests, coverage, pre-commit, commit (2026-10-10) diff --git a/install.sh b/install.sh index d98bc9c..6320397 100755 --- a/install.sh +++ b/install.sh @@ -64,6 +64,25 @@ install_web_user_unit() { install_web_user_unit +# The desktop entry is how the UI gets opened day to day (./run.sh with no +# arguments does the same), so it is installed with the unit that serves it. +install_desktop_entry() { + local target_user home_dir dst + target_user="${SUDO_USER:-$USER}" + home_dir="$(getent passwd "$target_user" | cut -d: -f6)" + [[ -n $home_dir ]] || return 0 + dst="$home_dir/.local/share/applications/steam-backlog-enforcer.desktop" + mkdir -p "$(dirname "$dst")" + sed "s|__REPO_DIR__|$SCRIPT_DIR|g" \ + "$SCRIPT_DIR/steam-backlog-enforcer.desktop" > "$dst" + if [[ $EUID -eq 0 ]]; then + chown "$target_user" "$dst" + fi + echo "Installed desktop entry: $dst" +} + +install_desktop_entry + # Install systemd service (system-level, runs as root). # # Non-interactive callers (install_core_system.sh, CI, `vm run`) have no stdin, @@ -113,6 +132,10 @@ if [[ "${ans,,}" == "y" ]]; then systemctl daemon-reload systemctl enable steam-backlog-enforcer echo "Service installed and enabled." + # The unit's RuntimeDirectory/StateDirectory (control socket, restart rate + # limit) are created when the service STARTS; a daemon that was already + # running keeps serving without ctl.sock until it is restarted. + echo " Web-UI control socket: /run/steam-backlog-enforcer/ctl.sock (created on start)" echo " Start now: sudo systemctl start steam-backlog-enforcer" echo " Check: sudo systemctl status steam-backlog-enforcer" echo " Logs: sudo journalctl -u steam-backlog-enforcer -f" diff --git a/pyproject.toml b/pyproject.toml index dedf116..ccc4c6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "websockets==17.2", # The earner registry (gaming budget terms); stdlib-only, so the root # daemon imports it from the desktop user's site-packages. - "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time", + "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time", ] # Optional extras. The `mcp` group is ONLY needed by the MCP server diff --git a/requirements.txt b/requirements.txt index 0b09d74..92ecb2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ aiohttp==3.14.4 bandit==1.9.4 codespell==2.4.3 coverage==7.16.2 -earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time +earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.7.0#subdirectory=earned_time howlongtobeatpy==1.0.24 mcp==2.3.0 mypy==2.4.0 diff --git a/run.sh b/run.sh index 5cb2547..f1f7052 100755 --- a/run.sh +++ b/run.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash # Launcher for the Steam Backlog Enforcer. -# Usage: ./run.sh [command] (defaults to "done" if no command given) +# Usage: ./run.sh open the web UI (the primary interface) +# ./run.sh run a CLI command (backend / recovery path) set -euo pipefail cd "$(dirname "$0")" if [[ $# -eq 0 ]]; then - exec python -m steam_backlog_enforcer.main "done" + exec scripts/open_ui.sh else exec python -m steam_backlog_enforcer.main "$@" fi diff --git a/scripts/open_ui.sh b/scripts/open_ui.sh new file mode 100755 index 0000000..585d051 --- /dev/null +++ b/scripts/open_ui.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Open the Steam Backlog Enforcer web UI in its own Chromium app window. +# +# The server is the steam-backlog-enforcer-web user unit; this only makes +# sure it is up and points an --app window at it. A dedicated profile keeps +# the window free of the main browser's tabs and extensions and gives it its +# own WM class, so i3 can treat it like a native app. +set -euo pipefail + +readonly UNIT="steam-backlog-enforcer-web" +readonly URL="${SBE_UI_URL:-http://127.0.0.1:8000/}" +readonly PROFILE_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/steam-backlog-enforcer/ui-profile" +readonly WM_CLASS="steam-backlog-enforcer" +readonly WAIT_SECONDS=15 + +find_browser() { + local candidate + for candidate in chromium google-chrome-stable; do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return 0 + fi + done + echo "Error: no chromium or google-chrome-stable on PATH." >&2 + return 1 +} + +# Start the unit if it is not running; Restart=always keeps it up after. +ensure_server() { + if ! systemctl --user is-active --quiet "$UNIT"; then + systemctl --user start "$UNIT" + fi + local waited=0 + until curl --silent --fail --max-time 1 --output /dev/null "$URL"; do + if ((waited >= WAIT_SECONDS)); then + echo "Error: $URL did not answer within ${WAIT_SECONDS}s." >&2 + echo "Check: journalctl --user -u $UNIT -n 50" >&2 + return 1 + fi + sleep 1 + waited=$((waited + 1)) + done +} + +main() { + local browser + browser="$(find_browser)" + ensure_server + mkdir -p "$PROFILE_DIR" + # setsid + nohup: the window must outlive the terminal that ran run.sh. + setsid nohup "$browser" \ + --app="$URL" \ + --class="$WM_CLASS" \ + --user-data-dir="$PROFILE_DIR" \ + --no-first-run \ + --no-default-browser-check \ + >/dev/null 2>&1 & + echo "Opened $URL" +} + +main "$@" diff --git a/steam-backlog-enforcer.desktop b/steam-backlog-enforcer.desktop new file mode 100644 index 0000000..b6e52fe --- /dev/null +++ b/steam-backlog-enforcer.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Steam Backlog Enforcer +Comment=Backlog, gaming budget and enforcer controls +Exec=__REPO_DIR__/scripts/open_ui.sh +Icon=applications-games +Terminal=false +Categories=Game; +StartupWMClass=steam-backlog-enforcer diff --git a/steam-backlog-enforcer.service b/steam-backlog-enforcer.service index 6e4d611..360d1d5 100644 --- a/steam-backlog-enforcer.service +++ b/steam-backlog-enforcer.service @@ -16,6 +16,19 @@ Environment=STEAM_ENFORCER_DESKTOP_USER=__DESKTOP_USER__ Environment=DISPLAY=:0 # Hardening: enforcer must not be easily killed. OOMScoreAdjust=-900 +# /run/steam-backlog-enforcer: the control socket for the web UI (ctl.sock) and +# the playtime refusal stub (gaming-blocked, bind-mounted over /usr/bin/steam +# while the budget is spent). Mode 0755 so the stub stays executable by +# everyone; the socket file carries its own 0660 + SO_PEERCRED check. +# Preserve=yes: with the default (no) systemd deletes the directory whenever the +# unit stops, which would delete the stub out from under a live block. +RuntimeDirectory=steam-backlog-enforcer +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes +# /var/lib/steam-backlog-enforcer: root-only state of the control socket (the +# persisted restart rate limit). +StateDirectory=steam-backlog-enforcer +StateDirectoryMode=0700 # Journal backstop. Steam's srt-logger runs in this unit's cgroup and mirrors # Steam's console into journald; 2026-09-12 it wrote 481k "joystick FFB" # lines in one boot (99% of the journal) and evicted two weeks of history. diff --git a/steam_backlog_enforcer/_achievement_skip.py b/steam_backlog_enforcer/_achievement_skip.py new file mode 100644 index 0000000..6ff031f --- /dev/null +++ b/steam_backlog_enforcer/_achievement_skip.py @@ -0,0 +1,138 @@ +"""Owned games known to have no achievements, so a library scan can skip them. + +A scan is bound by the Steam request rate (``SteamAPIClient._max_rps``, one +request per owned game), and about a quarter of a typical library has no +achievements at all. A game is skipped only while all three hold: + +- Steam does not flag it ``has_community_visible_stats``; +- it is not in the last snapshot (a game can have achievements without the + flag, so the flag alone is not trusted); +- a scan less than :data:`RECHECK_AFTER_SECONDS` ago found no achievements. + +So a game that later gains achievements without gaining the flag is still +picked up, at the latest that long after. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING, Any, Final + +from steam_backlog_enforcer import config +from steam_backlog_enforcer._snapshot import load_snapshot +from steam_backlog_enforcer.config import _atomic_write + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + +RECHECK_AFTER_SECONDS: Final = 30 * 86_400 +_FILE_NAME: Final = "no_achievements_cache.json" +_NO_STATS_STATUS: Final = 400 + + +def _cache_file() -> Path: + """Resolved per call, so a redirected ``CONFIG_DIR`` is honoured.""" + return config.CONFIG_DIR / _FILE_NAME + + +def _load_checked(steam_id: str) -> dict[int, float]: + """Map app id -> when a scan last found it without achievements. + + Returns: + An empty map when the file is missing, corrupt or for another account. + """ + try: + data = json.loads(_cache_file().read_text(encoding="utf-8")) + except OSError, ValueError: + return {} + if not isinstance(data, dict) or data.get("steam_id") != steam_id: + return {} + checked: dict[int, float] = {} + for key, when in dict(data.get("checked", {})).items(): + try: + checked[int(key)] = float(when) + except TypeError, ValueError: + continue + return checked + + +def is_no_stats_answer(exc: Exception) -> bool: + """Whether a failed achievements request was Steam saying "no stats". + + ``GetPlayerAchievements`` answers a game without stats with HTTP 400 and + ``{"playerstats": {"error": "Requested app has no stats", "success": + false}}``. That is a real answer; any other failure (timeout, 5xx, 429) + says nothing about the game. + """ + response = getattr(exc.__cause__, "response", None) + if response is None or response.status_code != _NO_STATS_STATUS: + return False + try: + stats = response.json().get("playerstats", {}) + except ValueError, AttributeError: + return False + return isinstance(stats, dict) and stats.get("success") is False + + +def _may_have_achievements(game: dict[str, Any]) -> bool: + """Whether Steam's owned-games entry says the game has stats.""" + return bool(game.get("has_community_visible_stats")) + + +def skippable_app_ids( + steam_id: str, owned: list[dict[str, Any]], now: float +) -> set[int]: + """Return the owned games a scan may skip right now (see module doc). + + Args: + steam_id: The account the cache must belong to. + owned: ``GetOwnedGames`` entries. + now: Current epoch seconds. + """ + checked = _load_checked(steam_id) + if not checked: + return set() + in_snapshot = {entry.get("app_id") for entry in load_snapshot() or []} + return { + game["appid"] + for game in owned + if not _may_have_achievements(game) + and game["appid"] not in in_snapshot + and now - checked.get(game["appid"], float("-inf")) < RECHECK_AFTER_SECONDS + } + + +def record_scan( + steam_id: str, + fetched: list[dict[str, Any]], + with_achievements: set[int], + now: float, +) -> None: + """Remember which fetched games came back without achievements. + + Never raises: losing the cache only costs the next scan its shortcut. + + Args: + steam_id: The account the cache belongs to. + fetched: The owned-games entries this scan asked Steam about. + with_achievements: App ids that came back with achievements. + now: Current epoch seconds. + """ + checked = _load_checked(steam_id) + for game in fetched: + app_id = game["appid"] + if app_id in with_achievements or _may_have_achievements(game): + checked.pop(app_id, None) + else: + checked[app_id] = now + payload = { + "steam_id": steam_id, + "checked": {str(app_id): when for app_id, when in sorted(checked.items())}, + } + try: + _atomic_write(_cache_file(), json.dumps(payload, indent=2) + "\n") + except OSError: + logger.warning("Could not save %s", _cache_file(), exc_info=True) diff --git a/steam_backlog_enforcer/_backups.py b/steam_backlog_enforcer/_backups.py new file mode 100644 index 0000000..6e2650f --- /dev/null +++ b/steam_backlog_enforcer/_backups.py @@ -0,0 +1,143 @@ +"""Timestamped backups of ``state.json``, taken before destructive commands. + +``reset`` wipes the assignment, finished list and pick history; a backup is +taken automatically first so a mistyped command costs nothing. Restoring is +itself destructive (it replaces the current state), so the current state is +backed up before every restore as well. + +Only ``state.json`` is copied. The lock and exception files +(``total_block_lock.json``, ``approved_exceptions.json``) are deliberately +left out: restoring an old copy of either would be a way to lift a lock. +For the same reason the restore command is gated like ``reset`` — no restore +while a manual-pick or total-block lock is active. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import UTC, datetime +import json +from pathlib import Path +import re +import secrets +import shutil + +from steam_backlog_enforcer import config as config_mod + +BACKUPS_DIR = Path.home() / ".local" / "state" / "steam-backlog-enforcer" / "backups" +_STATE_NAME = "state.json" +_META_NAME = "meta.json" +_ID_PATTERN = re.compile(r"^\d{8}T\d{6}Z-[0-9a-f]{6}$") + + +@dataclass(frozen=True) +class StateBackup: + """One backup, shaped like the TS ``StateBackup`` contract type. + + Attributes: + id: Sortable id: UTC timestamp plus a random suffix. + created_at: ISO-8601 UTC creation time. + reason: Why it was taken (``"before reset"``, ...). + size_bytes: Size of the backed-up ``state.json``. + """ + + id: str + created_at: str + reason: str + size_bytes: int + + def to_json(self) -> dict[str, object]: + """Return the JSON object the web API serves.""" + return asdict(self) + + +class BackupError(Exception): + """A backup could not be found, read or restored.""" + + +def _backup_dir(backup_id: str) -> Path: + """Resolve a backup id to its directory, refusing anything path-like.""" + if not _ID_PATTERN.fullmatch(backup_id): + msg = f"Not a backup id: {backup_id!r}" + raise BackupError(msg) + return BACKUPS_DIR / backup_id + + +def create_backup(reason: str) -> StateBackup | None: + """Copy the current ``state.json`` into a new backup. + + Args: + reason: Short human text stored with the backup. + + Returns: + The new backup, or ``None`` when there is no state file to back up. + """ + source = config_mod.STATE_FILE + if not source.exists(): + return None + now = datetime.now(UTC) + backup_id = f"{now.strftime('%Y%m%dT%H%M%SZ')}-{secrets.token_hex(3)}" + target = BACKUPS_DIR / backup_id + target.mkdir(parents=True, mode=0o700) + shutil.copy2(source, target / _STATE_NAME) + meta = {"created_at": now.isoformat(), "reason": reason} + (target / _META_NAME).write_text(json.dumps(meta) + "\n", encoding="utf-8") + return _read_backup(target) + + +def _read_backup(path: Path) -> StateBackup | None: + """Load one backup directory, or ``None`` if it is incomplete.""" + try: + meta = json.loads((path / _META_NAME).read_text(encoding="utf-8")) + size = (path / _STATE_NAME).stat().st_size + except OSError, ValueError: + return None + return StateBackup( + id=path.name, + created_at=str(meta.get("created_at", "")), + reason=str(meta.get("reason", "")), + size_bytes=size, + ) + + +def list_backups() -> list[StateBackup]: + """Return every readable backup, newest first.""" + if not BACKUPS_DIR.is_dir(): + return [] + found = ( + _read_backup(path) + for path in BACKUPS_DIR.iterdir() + if _ID_PATTERN.fullmatch(path.name) + ) + return sorted( + (backup for backup in found if backup is not None), + key=lambda backup: backup.id, + reverse=True, + ) + + +def restore_backup(backup_id: str) -> StateBackup | None: + """Replace ``state.json`` with a backup's copy. + + The state being replaced is backed up first, so a restore can be undone + by restoring that newer backup. + + Args: + backup_id: Id from :func:`list_backups`. + + Returns: + The backup of the state that was replaced, if there was one. + + Raises: + BackupError: If the id is malformed or the backup is unreadable. + """ + source = _backup_dir(backup_id) / _STATE_NAME + try: + data = source.read_text(encoding="utf-8") + json.loads(data) + except (OSError, ValueError) as exc: + msg = f"Backup {backup_id} is unreadable: {exc}" + raise BackupError(msg) from exc + previous = create_backup(f"before restoring {backup_id}") + config_mod.atomic_write(config_mod.STATE_FILE, data) + return previous diff --git a/steam_backlog_enforcer/_budget_log_tail.py b/steam_backlog_enforcer/_budget_log_tail.py index e49b2e6..9aa4352 100644 --- a/steam_backlog_enforcer/_budget_log_tail.py +++ b/steam_backlog_enforcer/_budget_log_tail.py @@ -23,7 +23,10 @@ import json import logging from typing import Any, Final -from steam_backlog_enforcer._playtime_log import budget_log_path +from steam_backlog_enforcer._playtime_log import ( + EVENT_MANUAL_ADJUSTMENT, + budget_log_path, +) from steam_backlog_enforcer._playtime_procs import process_name logger = logging.getLogger(__name__) @@ -79,7 +82,10 @@ def _last_record(*, demo: bool) -> dict[str, Any] | None: record = json.loads(line) except ValueError: continue - if isinstance(record, dict): + # A manual adjustment (e.g. a gaming reset) is an audit note, not a + # tick verdict: returning it would blank the live status until the + # next verdict change or heartbeat. + if isinstance(record, dict) and record.get("event") != EVENT_MANUAL_ADJUSTMENT: return record return None diff --git a/steam_backlog_enforcer/_cmd_done.py b/steam_backlog_enforcer/_cmd_done.py index 9f11dac..a0ed690 100644 --- a/steam_backlog_enforcer/_cmd_done.py +++ b/steam_backlog_enforcer/_cmd_done.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import sys from typing import TYPE_CHECKING from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached @@ -13,6 +12,12 @@ from steam_backlog_enforcer._hltb_confidence import ( from steam_backlog_enforcer._hltb_types import ( load_hltb_polls_cache, ) +from steam_backlog_enforcer._own_pick import ( + OWN_PICK_OPTION, + OWN_PICK_VALUE, + prompt_own_pick, +) +from steam_backlog_enforcer._prompter import PromptOption, current_prompter from steam_backlog_enforcer._snapshot import load_snapshot from steam_backlog_enforcer.game_install import ( _echo, @@ -24,38 +29,42 @@ if TYPE_CHECKING: _REASSIGN_REFRESH_LIMIT = 50 _SKIP_DAYS = 7 +_KEEP = "keep" +_SKIP = "skip" logger = logging.getLogger(__name__) -def _prompt_keep_or_skip(game: GameInfo) -> bool: +def _prompt_keep_or_skip( + game: GameInfo, games: list[GameInfo], state: State +) -> bool | GameInfo: """Ask the user whether to keep the freshly-picked ``game``. Returns ``True`` to accept the pick, ``False`` to skip it (which the - caller will translate into a 7-day skip entry on ``State``). When - stdin is not a TTY (e.g. background daemon, piped invocation), the - pick is accepted silently to preserve the legacy non-interactive - behaviour. + caller will translate into a 7-day skip entry on ``State``), or the game + the user searched for when they pick their own instead. When no human + can answer (a CLI whose stdin is not a TTY: background daemon, piped + invocation), the pick is accepted silently to preserve the legacy + non-interactive behaviour. A web job always asks. """ - if not sys.stdin.isatty(): + prompter = current_prompter() + if not prompter.interactive: return True hours_str = "" if game.completionist_hours > 0: hours_str = f" (~{game.completionist_hours:.1f}h leisure+dlc)" _echo(f"\n Next pick: {game.name} (AppID={game.app_id}){hours_str}") + options = [ + PromptOption(value=_KEEP, label=f"Keep {game.name}"), + PromptOption(value=_SKIP, label=f"Skip it for {_SKIP_DAYS} days"), + OWN_PICK_OPTION, + ] while True: - try: - answer = ( - input(f" Keep this game? [Y/n] (n = skip for {_SKIP_DAYS} days): ") - .strip() - .lower() - ) - except EOFError: - return True - if answer in {"", "y", "yes"}: - return True - if answer in {"n", "no"}: - return False - _echo(" Please answer 'y' or 'n'.") + answer = prompter.choice(" Keep this game?", options) + if answer != OWN_PICK_VALUE: + return answer == _KEEP + own = prompt_own_pick(games, state) + if own is not None: + return own def _backfill_polls_for_finished( diff --git a/steam_backlog_enforcer/_cmd_done_finalize.py b/steam_backlog_enforcer/_cmd_done_finalize.py index 215efb3..697a568 100644 --- a/steam_backlog_enforcer/_cmd_done_finalize.py +++ b/steam_backlog_enforcer/_cmd_done_finalize.py @@ -22,6 +22,7 @@ from steam_backlog_enforcer._cmd_done import ( ) from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached from steam_backlog_enforcer._hltb_types import load_hltb_cache +from steam_backlog_enforcer._progress import current_progress from steam_backlog_enforcer._snapshot import load_snapshot from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.enforcer import ( @@ -70,7 +71,12 @@ def _finalize_completion( skip = set(state.finished_app_ids) | state.active_skipped_ids() _refresh_uncached_shortlist_hours(games, hltb_cache, skip) _apply_cached_hours_to_games(games, hltb_cache) - pick_next_game(games, state, config, on_select=_prompt_keep_or_skip) + pick_next_game( + games, + state, + config, + on_select=lambda g: _prompt_keep_or_skip(g, games, state), + ) if state.current_app_id in {None, game.app_id}: # Nothing new was accepted, so the released game stays assigned. @@ -160,7 +166,10 @@ def cmd_done(config: Config, state: State) -> None: app_id = state.current_app_id _echo(f"Checking {game_name} (AppID={app_id})...") + progress = current_progress() + progress.phase("Fetching achievements from Steam", 1) game = client.refresh_single_game(app_id, game_name) + progress.advance(game_name) if game is None: _echo(" Could not fetch achievement data from Steam.") return diff --git a/steam_backlog_enforcer/_cmd_playtime.py b/steam_backlog_enforcer/_cmd_playtime.py index a6134a3..bcdbfdc 100644 --- a/steam_backlog_enforcer/_cmd_playtime.py +++ b/steam_backlog_enforcer/_cmd_playtime.py @@ -1,6 +1,6 @@ """CLI handlers for the daily gaming budget. -Every ``_echo`` / ``input`` / ``sys.exit`` for this feature lives here rather +Every ``_echo`` / prompt / ``sys.exit`` for this feature lives here rather than in :mod:`steam_backlog_enforcer._playtime` or :mod:`steam_backlog_enforcer._playtime_block`, so the MCP server can import those modules' leaf helpers without any risk of writing to stdout — which is @@ -9,24 +9,22 @@ the JSON-RPC channel. from __future__ import annotations -from datetime import UTC, datetime import os from typing import TYPE_CHECKING from steam_backlog_enforcer._enforce_loop import do_enforce -from steam_backlog_enforcer._gaming_days import gaming_day_key +from steam_backlog_enforcer._gaming_reset import reset_today from steam_backlog_enforcer._playtime_block import ( block_targets, mounted_targets, release_block, ) from steam_backlog_enforcer._playtime_state import ( - PlaytimeState, load_state, rules_for, - save_state, state_path, ) +from steam_backlog_enforcer._prompter import confirm_phrase from steam_backlog_enforcer.game_install import _echo if TYPE_CHECKING: @@ -92,11 +90,11 @@ def cmd_gaming_status(config: Config, _state: State) -> None: _echo(f" {target}") -def cmd_gaming_reset(config: Config, _state: State) -> int: +def cmd_gaming_reset(_config: Config, _state: State) -> int: """Zero today's gaming counter and lift the block. Args: - config: Enforcer configuration. + _config: Unused; required by the command dispatch signature. _state: Unused; required by the command dispatch signature. Returns: @@ -107,17 +105,12 @@ def cmd_gaming_reset(config: Config, _state: State) -> int: return 1 _echo("This resets today's gaming counter and lifts the block.") - if input("Type YES to confirm: ").strip() != "YES": + if not confirm_phrase("gaming-reset", "Reset today's gaming budget"): _echo("Aborted.") return 1 - released = release_block() - now = datetime.now(UTC).astimezone() - save_state( - PlaytimeState(day_key=gaming_day_key(now), last_tick_at=now.timestamp()), - demo=rules_for(config, demo=False).demo, - ) - _echo(f"Gaming counter reset. Released {len(released)} mount(s).") + outcome = reset_today(source="cli") + _echo(f"Gaming counter reset. Released {len(outcome.released)} mount(s).") return 0 diff --git a/steam_backlog_enforcer/_command_catalog.py b/steam_backlog_enforcer/_command_catalog.py new file mode 100644 index 0000000..75dc8ac --- /dev/null +++ b/steam_backlog_enforcer/_command_catalog.py @@ -0,0 +1,141 @@ +"""The server-driven command catalog: ``GET /api/commands``. + +One ``CommandSpec`` (``web/src/api/contract.ts``) per CLI command, so the UI +renders every control from data instead of hard-coding commands, phrases or +lock rules. Descriptions come from the CLI's own help table +(:mod:`steam_backlog_enforcer._command_help`), friction from +:data:`steam_backlog_enforcer._friction.FRICTION`, params from +:data:`steam_backlog_enforcer._command_params.PARAMS`, job flags from the job +registry and ``locked_reason`` from the CLI's lock gate — nothing here is a +second copy of a rule that lives elsewhere. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, Literal + +from steam_backlog_enforcer._command_gate import lock_reason +from steam_backlog_enforcer._command_help import ALL_COMMAND_DESCRIPTIONS +from steam_backlog_enforcer._command_params import PARAMS +from steam_backlog_enforcer._friction import FRICTION +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.jobs._specs import JOB_FLAGS + +if TYPE_CHECKING: + from steam_backlog_enforcer._friction import Friction + +Category = Literal["backlog", "library", "gaming", "store", "system"] +Kind = Literal["view", "job", "screen"] + + +@dataclass(frozen=True) +class _Entry: + """How the UI surfaces one command (the parts not derived elsewhere). + + ``mutating`` / ``cancellable`` / ``privileged`` here apply to views and + screens only; for ``job`` commands they come from the job registry. + """ + + category: Category + kind: Kind + view_endpoint: str | None = None + mutating: bool = False + cancellable: bool = False + privileged: bool = False + + +def _view(category: Category, endpoint: str) -> _Entry: + """A read-only command rendered from a GET endpoint.""" + return _Entry(category, "view", view_endpoint=endpoint) + + +def _job(category: Category) -> _Entry: + """A command that runs as a job (flags from the registry).""" + return _Entry(category, "job") + + +# Order is the UI's default order: everyday backlog flow first. +CATALOG: Final[dict[str, _Entry]] = { + "status": _view("backlog", "/api/status"), + "list": _view("backlog", "/api/dataset"), + "stats": _view("backlog", "/api/stats"), + "check": _job("backlog"), + "done": _job("backlog"), + "scan": _job("backlog"), + "pick": _job("backlog"), + "pick-manual": _job("backlog"), + "abandon-pick": _job("backlog"), + "installed": _view("library", "/api/installed"), + "install": _job("library"), + "uninstall": _job("library"), + "hide": _job("library"), + "unhide": _job("library"), + "add-exception": _job("library"), + "gaming-status": _view("gaming", "/api/budget"), + "gaming-reset": _job("gaming"), + "gaming-unblock": _job("gaming"), + "block-gaming": _job("gaming"), + "buy-dlc": _job("store"), + "unblock": _job("store"), + "reset": _job("system"), + "setup": _Entry("system", "screen", mutating=True), + # The daemon screen. Its only action is a rate-limited restart through + # the daemon; the 60-second demo runs as a job (params: demo=1). + "enforce": _Entry("system", "screen", mutating=True, privileged=True), + "serve": _Entry("system", "screen"), +} + + +def _friction_json(friction: Friction | None) -> dict[str, object] | None: + """The contract's ``FrictionSpec``, or ``None``.""" + if friction is None: + return None + out: dict[str, object] = {"phrase_template": friction.phrase_template} + if friction.countdown_seconds: + out["countdown_seconds"] = friction.countdown_seconds + return out + + +def _spec( + name: str, entry: _Entry, description: str, locked: str | None +) -> dict[str, object]: + """One ``CommandSpec`` object.""" + job = JOB_FLAGS.get(name) if entry.kind == "job" else None + spec: dict[str, object] = { + "name": name, + "description": description, + "category": entry.category, + "kind": entry.kind, + "params": [p.to_json() for p in PARAMS.get(name, ())], + "friction": _friction_json(FRICTION.get(name)), + "privileged": job.privileged if job else entry.privileged, + "mutating": job.mutating if job else entry.mutating, + "cancellable": job.cancellable if job else entry.cancellable, + "locked_reason": locked, + } + if entry.view_endpoint is not None: + spec["view_endpoint"] = entry.view_endpoint + return spec + + +def command_specs( + config: Config | None = None, state: State | None = None +) -> list[dict[str, object]]: + """Build the ``CommandSpec[]`` payload, locks evaluated right now. + + Args: + config: Loaded configuration (loaded here when omitted). + state: Loaded state (loaded here when omitted). + """ + config = config or Config.load() + state = state or State.load() + return [ + _spec( + name, + entry, + ALL_COMMAND_DESCRIPTIONS[name], + lock_reason(name, config, state), + ) + for name, entry in CATALOG.items() + ] diff --git a/steam_backlog_enforcer/_command_gate.py b/steam_backlog_enforcer/_command_gate.py new file mode 100644 index 0000000..44a6815 --- /dev/null +++ b/steam_backlog_enforcer/_command_gate.py @@ -0,0 +1,64 @@ +"""Why a command may not run right now: the lock rules, side-effect free. + +The pure half of the gate in :mod:`steam_backlog_enforcer.main._gate`: the +not-configured check, then the total gaming block, then the manual pick lock +— the order :func:`steam_backlog_enforcer.main.main` always used. It lives +outside ``main`` so the web server, which ``main`` itself loads (``main`` → +``misc`` → ``_web_server``), can ask it at import time without a cycle. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._actions import is_manual_pick_locked +from steam_backlog_enforcer._allowed_games import active_manual_picks +from steam_backlog_enforcer._command_locks import ( + _MANUAL_LOCK_EXEMPT_COMMANDS, + _TOTAL_BLOCK_EXEMPT_COMMANDS, +) +from steam_backlog_enforcer._total_block import ( + get_total_block_status, + is_total_block_active, +) + +if TYPE_CHECKING: + from steam_backlog_enforcer.config import Config, State + +# Usable before an API key exists: setup creates one, add-exception never +# talks to Steam. +UNCONFIGURED_OK: Final = frozenset({"setup", "add-exception"}) +NOT_CONFIGURED_MSG: Final = "Not configured. Run 'setup' first." + + +def lock_reason(command: str, config: Config, state: State | None) -> str | None: + """Say why *command* may not run right now, without printing anything. + + Args: + command: Canonical command name (never raw argv). + config: Loaded configuration. + state: Loaded state; ``None`` skips the manual-pick check, for callers + that have no state yet. + + Returns: + A one-line reason, or ``None`` when the command may run. + """ + if command not in UNCONFIGURED_OK and not config.steam_api_key: + return NOT_CONFIGURED_MSG + if is_total_block_active() and command not in _TOTAL_BLOCK_EXEMPT_COMMANDS: + days = get_total_block_status().days_remaining + allowed = ", ".join(sorted(_TOTAL_BLOCK_EXEMPT_COMMANDS)) + return ( + f"Total gaming block active ({days:.1f} day(s) left). Allowed: {allowed}." + ) + if ( + state is not None + and is_manual_pick_locked(state) + and command not in _MANUAL_LOCK_EXEMPT_COMMANDS + ): + names = ", ".join(str(p["game_name"]) for p in active_manual_picks(state)) + return ( + f"Manual pick lock active ({names}): earn a new achievement first. " + f"Allowed: {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}." + ) + return None diff --git a/steam_backlog_enforcer/_command_help.py b/steam_backlog_enforcer/_command_help.py new file mode 100644 index 0000000..5f8dc7e --- /dev/null +++ b/steam_backlog_enforcer/_command_help.py @@ -0,0 +1,50 @@ +"""One-line descriptions of every CLI command, in help order. + +The CLI's usage text and the web command catalog (``GET /api/commands``) +both read these. They live outside ``main`` so the catalog, which the web +server loads from inside ``main`` (``main`` → ``misc`` → ``_web_server``), +can import them at the top without a cycle. +""" + +from __future__ import annotations + +from typing import Final + +from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS + +# Commands dispatched through ``main.COMMANDS`` (handler takes config, state). +COMMAND_DESCRIPTIONS: Final[dict[str, str]] = { + "scan": "Scan library & assign a game", + "check": "Check assigned game for a new achievement", + "status": "Show current status", + "list": "List games from snapshot", + "install": "Install the assigned game", + "hide": "Hide all non-assigned games in library", + "unhide": "Unhide all games in library", + "buy-dlc": "Unblock the store for 15 min to buy a game/DLC", + "reset": "Reset all state", + "installed": "List installed games", + "uninstall": "Uninstall all non-assigned games", + "setup": "Run first-time setup", + "done": "Move on after a new achievement, pick next", + "pick": "Manually pick your next game from candidates", + "stats": "Show backlog completion-time estimates", + "gaming-status": "Show today's gaming time and block state", + "gaming-reset": "Reset today's gaming counter (root + phrase)", +} + +# Commands with non-standard arg handling (shown in help but not in COMMANDS). +EXTRA_COMMAND_DESCRIPTIONS: Final[dict[str, str]] = { + "add-exception": "Request 24h-locked whitelist exception (use --reason)", + "unblock": "Unblock the store for [minutes] (default 15, max 30)", + "serve": "Start the web UI (--port N; replaces a stale server)", + "pick-manual": f"Pick a game by app_id, lock enforcer for {MANUAL_LOCK_DAYS} days", + "abandon-pick": "Undo a manual pick at any time (needs app_id)", + "block-gaming": "Block ALL gaming for days, no in-app undo", + "enforce": "Run enforcer: block, uninstall, kill, hide (--demo for a 60s budget)", + "gaming-unblock": "Force-release playtime bind mounts (root; recovery hatch)", +} + +ALL_COMMAND_DESCRIPTIONS: Final[dict[str, str]] = ( + COMMAND_DESCRIPTIONS | EXTRA_COMMAND_DESCRIPTIONS +) diff --git a/steam_backlog_enforcer/_command_locks.py b/steam_backlog_enforcer/_command_locks.py new file mode 100644 index 0000000..b6d8894 --- /dev/null +++ b/steam_backlog_enforcer/_command_locks.py @@ -0,0 +1,45 @@ +"""Which commands stay usable under each lock. + +A leaf module (no imports) so the CLI dispatcher in ``main._shared`` and the +root daemon's control socket consult ONE table: a lock the daemon enforced +differently from the CLI would be a way around it for anyone who can reach +the socket. ``main._shared`` cannot be imported by the daemon directly -- +``main`` imports the enforce loop, which starts the control socket. +""" + +from __future__ import annotations + +# Commands that remain usable while the manual pick lock is active. +# Principle: only what is needed to release the lock (done/check) or +# that cannot change the game assignment (status, enforce, setup, serve). +_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset( + { + "done", + "check", + "status", + "enforce", + "setup", + "serve", + "abandon-pick", + # Allowed so a second game can be locked in alongside the first; the + # cap inside cmd_pick_manual is what stops this being a way out. + "pick-manual", + # The daily gaming budget is orthogonal to which game is assigned, and + # gaming-unblock is a recovery hatch for a stuck bind mount - locking + # it behind a manual pick would leave Steam masked with no way back. + "gaming-status", + "gaming-unblock", + "gaming-reset", + } +) + +# Commands that remain usable while a total gaming block is active. Far +# stricter than _MANUAL_LOCK_EXEMPT_COMMANDS: no done/pick/reset/ +# add-exception - there is no in-app way to shorten a total block. +# +# gaming-unblock is included because a playtime bind mount makes the total +# block's own `pacman -R steam` fail EBUSY - it must stay reachable exactly +# when the two collide. gaming-reset is NOT included: it shortens enforcement. +_TOTAL_BLOCK_EXEMPT_COMMANDS = frozenset( + {"status", "enforce", "gaming-status", "gaming-unblock"} +) diff --git a/steam_backlog_enforcer/_command_params.py b/steam_backlog_enforcer/_command_params.py new file mode 100644 index 0000000..78c21dd --- /dev/null +++ b/steam_backlog_enforcer/_command_params.py @@ -0,0 +1,149 @@ +"""Typed parameters of the commands the web UI can run. + +The CLI parses ``sys.argv`` per command; the web sends a flat JSON object. +This table is the one description of that object, used twice: the command +catalog serves it to the UI as ``ParamSpec[]`` (so forms are server-driven), +and the job store validates every request against it before a job exists. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final, Literal + +from steam_backlog_enforcer._store_window import ( + DEFAULT_WINDOW_MINUTES, + MAX_WINDOW_MINUTES, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + +ParamType = Literal["int", "string", "app_id"] +ParamValue = int | str + + +@dataclass(frozen=True) +class ParamSpec: + """One parameter, shaped like the TS ``ParamSpec`` contract type.""" + + name: str + label: str + type: ParamType + required: bool = True + min: int | None = None + max: int | None = None + default: ParamValue | None = None + help: str | None = None + + def to_json(self) -> dict[str, object]: + """Return the contract object, omitting unset optional keys.""" + out: dict[str, object] = { + "name": self.name, + "label": self.label, + "type": self.type, + "required": self.required, + } + for key in ("min", "max", "default", "help"): + value = getattr(self, key) + if value is not None: + out[key] = value + return out + + +_APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1) + +PARAMS: Final[dict[str, tuple[ParamSpec, ...]]] = { + "pick-manual": (_APP_ID,), + "abandon-pick": (_APP_ID,), + "add-exception": ( + _APP_ID, + ParamSpec( + "reason", + "Reason", + "string", + help="A genuine justification of at least 5 words.", + ), + ), + "block-gaming": (ParamSpec("days", "Days", "int", min=1),), + "unblock": ( + ParamSpec( + "minutes", + "Minutes", + "int", + required=False, + min=1, + max=MAX_WINDOW_MINUTES, + default=DEFAULT_WINDOW_MINUTES, + ), + ), + # Only the 60-second demo budget runs as a job; restarting the real + # enforcer is the daemon's job. + "enforce": ( + ParamSpec( + "demo", + "Demo mode (1 = 60-second budget)", + "int", + required=False, + min=0, + max=1, + default=0, + ), + ), + # Not a CLI command: POST /api/backups/{id}/restore runs it as a job. + "restore-backup": (ParamSpec("backup_id", "Backup id", "string"),), +} + + +class InvalidParamsError(ValueError): + """A request's params do not match the command's :class:`ParamSpec` list.""" + + +def _coerce(spec: ParamSpec, raw: object) -> ParamValue: + """Convert one raw JSON value to the spec's type, enforcing its bounds.""" + if spec.type == "string": + if not isinstance(raw, str) or not raw.strip(): + msg = f"{spec.name} must be a non-empty string" + raise InvalidParamsError(msg) + return raw.strip() + # bool is an int subclass in Python; true/false is never a count. + if isinstance(raw, bool) or not isinstance(raw, (int, str)): + msg = f"{spec.name} must be a whole number" + raise InvalidParamsError(msg) + try: + value = int(raw) + except ValueError as exc: + msg = f"{spec.name} must be a whole number, got {raw!r}" + raise InvalidParamsError(msg) from exc + if spec.min is not None and value < spec.min: + msg = f"{spec.name} must be at least {spec.min}" + raise InvalidParamsError(msg) + if spec.max is not None and value > spec.max: + msg = f"{spec.name} must be at most {spec.max}" + raise InvalidParamsError(msg) + return value + + +def validate_params( + command: str, params: Mapping[str, object] +) -> dict[str, ParamValue]: + """Return *params* coerced to their declared types, defaults filled in. + + Raises: + InvalidParamsError: On an unknown, missing or out-of-range param. + """ + specs = PARAMS.get(command, ()) + unknown = set(params) - {spec.name for spec in specs} + if unknown: + msg = f"Unknown param(s) for {command}: {', '.join(sorted(unknown))}" + raise InvalidParamsError(msg) + clean: dict[str, ParamValue] = {} + for spec in specs: + if spec.name in params: + clean[spec.name] = _coerce(spec, params[spec.name]) + elif spec.default is not None: + clean[spec.name] = spec.default + elif spec.required: + msg = f"Missing required param: {spec.name}" + raise InvalidParamsError(msg) + return clean diff --git a/steam_backlog_enforcer/_config_setup.py b/steam_backlog_enforcer/_config_setup.py index d8b77c1..80d7c34 100644 --- a/steam_backlog_enforcer/_config_setup.py +++ b/steam_backlog_enforcer/_config_setup.py @@ -8,26 +8,44 @@ already treated it as its own unit. from __future__ import annotations +from dataclasses import replace +import json import sys from steam_backlog_enforcer import config as config_mod +from steam_backlog_enforcer._prompter import current_prompter from steam_backlog_enforcer.config import Config def interactive_setup() -> Config: """Run first-time interactive setup.""" - api_key = input("Enter your Steam Web API key: ").strip() + prompter = current_prompter() + api_key = prompter.text("Enter your Steam Web API key") if not api_key: sys.exit(1) - steam_id = input("Enter your Steam64 ID: ").strip() + steam_id = prompter.text("Enter your Steam64 ID") if not steam_id: sys.exit(1) - config = Config(steam_api_key=api_key, steam_id=steam_id) - config.save() + return save_credentials(api_key, steam_id) + + +def save_credentials(api_key: str, steam_id: str) -> Config: + """Store *api_key* and *steam_id* in the config, owner-only. + + The non-interactive half of setup, so the web setup screen stores the + credentials exactly the way the CLI does. Every other setting already in + the config is kept: re-entering a key must not reset the user's choices, + including keys this version does not model (hand-added or legacy ones). + """ + config = replace(Config.load(), steam_api_key=api_key, steam_id=steam_id) # Read through the module, not an import-time binding: the path is # redirected in tests, and the API key lives here — it must not become # world-readable, nor be written to the real config during a test run. - config_mod.CONFIG_FILE.chmod(0o600) + path = config_mod.CONFIG_FILE + on_disk = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {} + merged = on_disk | config.__dict__ + config_mod.atomic_write(path, json.dumps(merged, indent=2) + "\n") + path.chmod(0o600) return config diff --git a/steam_backlog_enforcer/_ctl_client.py b/steam_backlog_enforcer/_ctl_client.py new file mode 100644 index 0000000..42b111c --- /dev/null +++ b/steam_backlog_enforcer/_ctl_client.py @@ -0,0 +1,214 @@ +"""Client for the root daemon's control socket (used by the web server). + +One typed function per allowlisted op. A socket that is missing, refuses the +connection, times out or is closed without an answer (which is how the daemon +turns away a uid it does not know) raises :class:`DaemonUnreachableError`; a request +the daemon understood but refused raises :class:`CtlError` carrying the +contract's error code. + +Imports nothing from the enforcer except the wire module, so the unprivileged +server can load it without pulling in the daemon's side effects. +""" + +from __future__ import annotations + +import socket +from typing import TYPE_CHECKING, Final, cast + +from steam_backlog_enforcer._ctl_protocol import ( + MAX_RESPONSE_BYTES, + SOCKET_PATH, + CtlError, + DaemonUnreachableError, + decode_line, + encode_line, +) +from steam_backlog_enforcer._ctl_types import ( + BlockResult, + DaemonInfo, + PendingReset, + ResetResult, + StoreWindow, +) + +if TYPE_CHECKING: + from pathlib import Path + + +# Most ops answer in well under a second; the ones that run privileged +# commands (iptables, pacman, guardctl) are given room, and every one may also +# wait up to a minute for the enforce loop's current pass to finish. +_DEFAULT_TIMEOUT: Final = 15.0 +_SLOW_TIMEOUT: Final = 150.0 +_BLOCK_TIMEOUT: Final = 600.0 + + +def call( + op: str, + args: dict[str, object] | None = None, + *, + timeout: float = _DEFAULT_TIMEOUT, + path: Path = SOCKET_PATH, +) -> dict[str, object]: + """Send one request and return the response's ``data``. + + Args: + op: Op name. + args: Op arguments. + timeout: Seconds to wait for connect and for the reply. + path: Socket path (overridable for tests). + + Returns: + The ``data`` object of a successful response. + + Raises: + DaemonUnreachableError: No daemon answered on the socket. + CtlError: The daemon refused or failed the op. + """ + conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + conn.settimeout(timeout) + try: + conn.connect(str(path)) + conn.sendall(encode_line({"op": op, "args": args or {}})) + raw = _read_reply(conn) + except OSError as exc: # missing, refused, permission denied, timeout, reset + msg = f"control socket {path}: {exc}" + raise DaemonUnreachableError(msg) from exc + finally: + conn.close() + if not raw.strip(): + msg = f"control socket {path} closed without answering (uid not permitted?)" + raise DaemonUnreachableError(msg) + return _unwrap(raw) + + +def _read_reply(conn: socket.socket) -> bytes: + """Read until the daemon closes the connection, within the size cap.""" + data = b"" + while chunk := conn.recv(65536): + data += chunk + if len(data) > MAX_RESPONSE_BYTES: + msg = "control socket reply too large" + raise OSError(msg) + return data + + +def _unwrap(raw: bytes) -> dict[str, object]: + """Decode a response line into its data, or raise its error.""" + try: + reply = decode_line(raw.split(b"\n", 1)[0]) + except ValueError as exc: + msg = "control socket sent a malformed reply" + raise DaemonUnreachableError(msg) from exc + if not isinstance(reply, dict): + msg = "control socket sent a malformed reply" + raise DaemonUnreachableError(msg) + if reply.get("ok") is True: + data = reply.get("data") + return cast("dict[str, object]", data) if isinstance(data, dict) else {} + extra = reply.get("data") + raise CtlError( + str(reply.get("error", "op_failed")), + str(reply.get("message", "")), + cast("dict[str, object]", extra) if isinstance(extra, dict) else None, + ) + + +def _pending(data: dict[str, object]) -> PendingReset: + """Build a :class:`PendingReset` from an arm/heartbeat reply.""" + return PendingReset( + pending_id=str(data["pending_id"]), + phrase=str(data["phrase"]), + armed_at=str(data["armed_at"]), + ready_at=str(data["ready_at"]), + heartbeat_interval_seconds=int(cast("int", data["heartbeat_interval_seconds"])), + ) + + +def ping() -> None: + """Check the daemon answers. Raises :class:`DaemonUnreachableError` if not.""" + call("ping") + + +def status() -> DaemonInfo: + """The daemon's state, start time, pid and next-restart time.""" + data = call("status") + until = data.get("restart_available_at") + return DaemonInfo( + state=str(data["state"]), + started_at=str(data["started_at"]), + pid=int(cast("int", data["pid"])), + restart_available_at=str(until) if until else None, + ) + + +def journal_tail(lines: int = 50) -> list[str]: + """The last *lines* (1-200) journal lines of the enforcer unit.""" + data = call("journal_tail", {"lines": lines}) + return [str(line) for line in cast("list[object]", data["lines"])] + + +def store_unblock(minutes: int, phrase: str) -> StoreWindow: + """Open the store for *minutes* (1-30); *phrase* is checked by the daemon.""" + data = call( + "store_unblock", + {"minutes": minutes, "phrase": phrase}, + timeout=_SLOW_TIMEOUT, + ) + return StoreWindow( + minutes=int(cast("int", data["minutes"])), until=str(data["until"]) + ) + + +def block_gaming(days: int, phrase: str) -> BlockResult: + """Start a total gaming block for *days* days. Irreversible from the app.""" + data = call( + "block_gaming", {"days": days, "phrase": phrase}, timeout=_BLOCK_TIMEOUT + ) + until = data.get("until") + return BlockResult( + days=int(cast("int", data["days"])), until=str(until) if until else None + ) + + +def gaming_unblock(phrase: str) -> list[str]: + """Release every playtime mount; returns the targets released.""" + data = call("gaming_unblock", {"phrase": phrase}, timeout=_SLOW_TIMEOUT) + return [str(path) for path in cast("list[object]", data["released"])] + + +def gaming_reset_arm(phrase: str) -> PendingReset: + """Start the gaming-reset countdown (or join the one already running).""" + return _pending(call("gaming_reset_arm", {"phrase": phrase})) + + +def gaming_reset_heartbeat(pending_id: str) -> PendingReset: + """Keep an armed reset alive; call at least every 10 s.""" + return _pending(call("gaming_reset_heartbeat", {"pending_id": pending_id})) + + +def gaming_reset_commit(pending_id: str, phrase: str) -> ResetResult: + """Run the reset once the countdown has finished.""" + data = call( + "gaming_reset_commit", + {"pending_id": pending_id, "phrase": phrase}, + timeout=_SLOW_TIMEOUT, + ) + return ResetResult( + day_key=str(data["day_key"]), + seconds_before=float(cast("float", data["seconds_before"])), + was_blocked=bool(data["was_blocked"]), + released=[str(path) for path in cast("list[object]", data["released"])], + ) + + +def gaming_reset_cancel(pending_id: str) -> None: + """Abandon an armed reset.""" + call("gaming_reset_cancel", {"pending_id": pending_id}) + + +def restart() -> str | None: + """Restart the daemon (rate limited); returns when the next one is allowed.""" + data = call("restart") + until = data.get("restart_available_at") + return str(until) if until else None diff --git a/steam_backlog_enforcer/_ctl_context.py b/steam_backlog_enforcer/_ctl_context.py new file mode 100644 index 0000000..065a729 --- /dev/null +++ b/steam_backlog_enforcer/_ctl_context.py @@ -0,0 +1,142 @@ +"""What every control-socket op shares: the daemon's state and the guards. + +The ops run on connection threads, the enforce loop on the main thread. The +``tick_lock`` is how they stay out of each other's way: the loop holds it for +one whole iteration and a mutating op holds it for its whole body, so an op +never sees (or clobbers) a half-finished tick. The loop only holds it while +working, never while sleeping between ticks, so an op waits at most one tick. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._actions import is_manual_pick_locked +from steam_backlog_enforcer._command_locks import ( + _MANUAL_LOCK_EXEMPT_COMMANDS, + _TOTAL_BLOCK_EXEMPT_COMMANDS, +) +from steam_backlog_enforcer._ctl_pending import PendingResets +from steam_backlog_enforcer._ctl_protocol import ( + BUSY, + INVALID_PARAMS, + LOCKED, + WRONG_PHRASE, + CtlError, +) +from steam_backlog_enforcer._friction import expected_phrase, phrase_matches +from steam_backlog_enforcer._total_block import is_total_block_active +from steam_backlog_enforcer.config import State + +if TYPE_CHECKING: + from collections.abc import Iterator + from datetime import datetime + import threading + + from steam_backlog_enforcer.config import Config + +# Longer than any single enforce iteration should take; an op that cannot get +# the lock by then reports ``busy`` instead of hanging its connection. +LOCK_WAIT_SECONDS: Final = 60.0 + + +@dataclass +class CtlContext: + """Shared state for the control socket's ops. + + Attributes: + config: The daemon's loaded configuration. + tick_lock: Held by the enforce loop for each iteration. + restart_event: Set by the ``restart`` op; the loop exits once it sees it. + started_at: When the daemon process started (UTC). + supervised: Whether systemd will bring the daemon back after an exit. + pending: Armed two-phase gaming resets. + """ + + config: Config + tick_lock: threading.Lock + restart_event: threading.Event + started_at: datetime + supervised: bool + pending: PendingResets = field(default_factory=PendingResets) + + +@contextmanager +def hold_tick(ctx: CtlContext) -> Iterator[None]: + """Hold the loop's lock for the body of a mutating op. + + Raises: + CtlError: ``busy`` if the loop does not release the lock in time. + """ + if not ctx.tick_lock.acquire(timeout=LOCK_WAIT_SECONDS): + raise CtlError(BUSY, "the enforcer is mid-pass; try again in a moment") + try: + yield + finally: + ctx.tick_lock.release() + + +def require_phrase(command: str, typed: object, **fields: object) -> None: + """Check the typed confirmation phrase for *command*. + + Raises: + CtlError: ``invalid_params`` if *typed* is not text, ``wrong_phrase`` + if it differs from the one ``_friction`` demands. + """ + if not isinstance(typed, str): + raise CtlError(INVALID_PARAMS, "phrase must be a string") + expected = expected_phrase(command, **fields) + if expected is None or not phrase_matches(expected, typed): + raise CtlError(WRONG_PHRASE, "the typed phrase does not match") + + +def int_arg(args: dict[str, object], name: str, low: int, high: int) -> int: + """Read an integer argument within ``[low, high]``. + + ``bool`` is rejected explicitly: ``True`` is an ``int`` in Python and would + otherwise pass as ``1``. + + Raises: + CtlError: ``invalid_params`` if absent, not an integer, or out of range. + """ + value = args.get(name) + if isinstance(value, bool) or not isinstance(value, int): + raise CtlError(INVALID_PARAMS, f"{name} must be an integer") + if not low <= value <= high: + raise CtlError(INVALID_PARAMS, f"{name} must be between {low} and {high}") + return value + + +def str_arg(args: dict[str, object], name: str) -> str: + """Read a non-empty string argument. + + Raises: + CtlError: ``invalid_params`` if absent or not a non-empty string. + """ + value = args.get(name) + if not isinstance(value, str) or not value: + raise CtlError(INVALID_PARAMS, f"{name} must be a non-empty string") + return value + + +def check_locks(command: str) -> None: + """Refuse *command* while a lock the CLI would honour is in force. + + Mirrors ``main._shared._enforce_total_block_lock`` and + ``_enforce_manual_pick_lock`` using the same tables, because the socket + would otherwise be the one door those locks do not cover. + + Args: + command: The CLI command name the op corresponds to. + + Raises: + CtlError: ``locked`` if a total block or the manual-pick lock forbids it. + """ + if is_total_block_active() and command not in _TOTAL_BLOCK_EXEMPT_COMMANDS: + raise CtlError(LOCKED, "a total gaming block is active") + if command not in _MANUAL_LOCK_EXEMPT_COMMANDS and is_manual_pick_locked( + State.load() + ): + raise CtlError(LOCKED, "a manual pick lock is active") diff --git a/steam_backlog_enforcer/_ctl_control.py b/steam_backlog_enforcer/_ctl_control.py new file mode 100644 index 0000000..581f37a --- /dev/null +++ b/steam_backlog_enforcer/_ctl_control.py @@ -0,0 +1,117 @@ +"""The enforce loop's handle on the control socket. + +``start_control`` is the only thing the loop calls. It never raises and never +blocks the enforcer: if the socket cannot be served (not root, demo run, no +desktop user, bind failure) the loop gets an inert handle and carries on +exactly as before. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import logging +import os +import pwd +import threading +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._ctl_context import CtlContext +from steam_backlog_enforcer._ctl_gap import record_exit +from steam_backlog_enforcer._ctl_server import CtlServer +from steam_backlog_enforcer._desktop_env import resolve_desktop_user +from steam_backlog_enforcer._playtime import playtime_tick + +if TYPE_CHECKING: + from steam_backlog_enforcer._playtime_session import PlaytimeSession + from steam_backlog_enforcer.config import Config + +logger = logging.getLogger(__name__) + + +class DaemonControl: + """Lock + restart flag shared with the control socket (inert if none).""" + + def __init__(self, ctx: CtlContext, server: CtlServer | None) -> None: + """Wrap the shared context and the (optional) running server. + + Args: + ctx: Shared op context; owns the tick lock and restart event. + server: The running socket server, or ``None`` when inert. + """ + self.ctx = ctx + self._server = server + + @property + def tick_lock(self) -> threading.Lock: + """Held by the loop for each iteration so ops never interleave with one.""" + return self.ctx.tick_lock + + @property + def restart_requested(self) -> bool: + """Whether the ``restart`` op has been accepted.""" + return self.ctx.restart_event.is_set() + + def flush_for_restart( + self, config: Config, session: PlaytimeSession, *, interval: float, demo: bool + ) -> bool: + """Bill the time up to now before the process exits for a restart. + + Everything else the enforcer relies on is already on disk after each + tick (the budget state, the cutoff time, fired warnings, the store + window deadline); the only thing a restart can lose is the seconds + since the last tick, so one more accounting tick closes that gap. Call + with the tick lock held. + + Returns: + Whether the loop should exit now. + """ + if not self.restart_requested: + return False + playtime_tick(config, interval=interval, session=session, demo=demo) + record_exit() + logger.warning("State flushed; exiting so systemd restarts the enforcer.") + return True + + def close(self) -> None: + """Stop serving and remove the socket file.""" + if self._server is not None: + self._server.stop() + + +def _is_supervised() -> bool: + """Whether systemd started this process (it sets ``INVOCATION_ID``).""" + return bool(os.environ.get("INVOCATION_ID")) + + +def start_control(config: Config, *, demo: bool) -> DaemonControl: + """Start the control socket if this process is the real root daemon. + + Args: + config: The daemon's configuration. + demo: Whether this is a ``--demo`` run (never serves: it could take the + socket from the real daemon). + + Returns: + A handle; inert when the socket is not being served. + """ + ctx = CtlContext( + config=config, + tick_lock=threading.Lock(), + restart_event=threading.Event(), + started_at=datetime.now(UTC), + supervised=_is_supervised(), + ) + if demo or os.geteuid() != 0: + return DaemonControl(ctx, None) + user = resolve_desktop_user() + try: + entry = pwd.getpwnam(user) if user else None + except KeyError: + entry = None + if entry is None or entry.pw_uid == 0: + # Fail closed: without a known unprivileged owner there is nobody the + # socket could safely be for. + logger.error("Control socket disabled: no desktop user (%r)", user) + return DaemonControl(ctx, None) + server = CtlServer(ctx, desktop_uid=entry.pw_uid, desktop_gid=entry.pw_gid) + return DaemonControl(ctx, server if server.start() else None) diff --git a/steam_backlog_enforcer/_ctl_gap.py b/steam_backlog_enforcer/_ctl_gap.py new file mode 100644 index 0000000..bbab9e3 --- /dev/null +++ b/steam_backlog_enforcer/_ctl_gap.py @@ -0,0 +1,89 @@ +"""Bill the downtime of a requested restart, so restarting is not free play. + +The budget measures wall-clock deltas between ticks and clamps each one to two +tick intervals (so a suspend cannot inflate the count). A restart is the one +gap that clamp would wrongly forgive: ``RestartSec`` plus interpreter start plus +the startup pass can be tens of seconds, and the first tick would bill only 6 s +of it -- repeatable free play every ten minutes. + +So the exiting daemon leaves a root-only marker with its exit time, and the +next daemon spends it on its first tick by widening that one tick's interval to +cover the gap. The marker is deleted before it is used, expires after +``MAX_GAP_SECONDS`` and only a *requested* restart writes one, so a crash or a +long outage never over-bills and the clamp still protects against suspends. +A tick with no qualifying process bills nothing whatever its interval. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +import time +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._playtime import playtime_tick +from steam_backlog_enforcer.config import _atomic_write + +if TYPE_CHECKING: + from steam_backlog_enforcer._playtime_session import PlaytimeSession + from steam_backlog_enforcer.config import Config + +logger = logging.getLogger(__name__) + +# Beside the restart rate limit (StateDirectory=, root only). +RESTART_EXIT_FILE: Final = Path("/var/lib/steam-backlog-enforcer/ctl_restart_exit.json") +MAX_GAP_SECONDS: Final = 300.0 +_MARKER_MODE: Final = 0o600 + + +def record_exit() -> None: + """Leave the exit-time marker for the next daemon. Never raises.""" + try: + _atomic_write( + RESTART_EXIT_FILE, + json.dumps({"exited_at": time.time()}) + "\n", + mode=_MARKER_MODE, + ) + except OSError: + logger.warning("Cannot record the restart exit time; the gap goes unbilled") + + +def _take_exit_time() -> float | None: + """Read and delete the marker; ``None`` if absent or unusable.""" + try: + raw = json.loads(RESTART_EXIT_FILE.read_text(encoding="utf-8")) + exited = raw["exited_at"] + except OSError, ValueError, KeyError, TypeError: + return None + finally: + RESTART_EXIT_FILE.unlink(missing_ok=True) + return float(exited) if isinstance(exited, int | float) else None + + +def settle_restart_gap( + config: Config, + session: PlaytimeSession, + *, + base_interval: float, + demo: bool, +) -> None: + """Bill the time since a requested restart's exit, once. + + Args: + config: Enforcer configuration. + session: The new daemon's logging session. + base_interval: The normal tick interval (lower bound for the widened one). + demo: Whether this is a demo run (never settles). + """ + exited = _take_exit_time() + if demo or exited is None: + return + gap = time.time() - exited + if not 0 < gap <= MAX_GAP_SECONDS: + return + logger.info("Billing %.0f s of restart downtime.", gap) + # accumulate() clamps a delta to 2 x interval; this admits exactly the gap. + playtime_tick( + config, interval=max(base_interval, gap / 2 + 1), session=session, demo=demo + ) diff --git a/steam_backlog_enforcer/_ctl_ops.py b/steam_backlog_enforcer/_ctl_ops.py new file mode 100644 index 0000000..5ed3d4e --- /dev/null +++ b/steam_backlog_enforcer/_ctl_ops.py @@ -0,0 +1,161 @@ +"""The allowlisted ops of the control socket. + +``OPS`` is the whole attack surface: a request naming anything else is +refused as an unknown command. There is no stop, no disable and no generic +command execution -- every op does one named, bounded thing. Each privileged +op re-checks its own typed phrase, argument bounds and the same locks the CLI +honours, because the web server in front of it is not trusted to have done so. +""" + +from __future__ import annotations + +import contextlib +import logging +import os +import subprocess +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._ctl_context import ( + check_locks, + hold_tick, + int_arg, + require_phrase, +) +from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError +from steam_backlog_enforcer._ctl_reset import RESET_OPS +from steam_backlog_enforcer._ctl_restart import RESTART_OPS, restart_available_at +from steam_backlog_enforcer._playtime_block import release_block +from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window +from steam_backlog_enforcer._total_block import ( + get_total_block_status, + start_total_block, +) +from steam_backlog_enforcer.config import CONFIG_DIR, STATE_FILE, State + +if TYPE_CHECKING: + from collections.abc import Callable + + from steam_backlog_enforcer._ctl_context import CtlContext + +logger = logging.getLogger(__name__) + +UNIT_NAME: Final = "steam-backlog-enforcer.service" +DEFAULT_JOURNAL_LINES: Final = 50 +MAX_JOURNAL_LINES: Final = 200 +_MAX_LINE_CHARS: Final = 500 +_JOURNAL_TIMEOUT_SECONDS: Final = 5 +_JOURNALCTL: Final = "/usr/bin/journalctl" +# A block is irreversible from the app; a typo like 3650 must not be one +# confirmation away. The phrase repeats the number, which is the other half. +MAX_BLOCK_DAYS: Final = 365 + + +def op_ping(_ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]: + """Liveness probe.""" + return {"pong": True} + + +def op_status(ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]: + """The daemon's state, start time, pid and when a restart is next allowed.""" + return { + "state": "restarting" if ctx.restart_event.is_set() else "running", + "started_at": ctx.started_at.isoformat(), + "pid": os.getpid(), + "restart_available_at": restart_available_at(), + } + + +def op_journal_tail(_ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """The last *lines* journal lines of the enforcer unit (capped).""" + lines = ( + int_arg(args, "lines", 1, MAX_JOURNAL_LINES) + if "lines" in args + else DEFAULT_JOURNAL_LINES + ) + try: + result = subprocess.run( + [ + _JOURNALCTL, + "-u", + UNIT_NAME, + "-n", + str(lines), + "--no-pager", + "-q", + "-o", + "short-iso", + ], + capture_output=True, + text=True, + errors="replace", + timeout=_JOURNAL_TIMEOUT_SECONDS, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise CtlError(OP_FAILED, f"journalctl failed: {exc}") from exc + return { + "lines": [line[:_MAX_LINE_CHARS] for line in result.stdout.splitlines()], + } + + +def _hand_back_state_file() -> None: + """Give state.json back to the desktop user if root just created it. + + ``State.save`` keeps an existing file's owner, but a file that did not + exist yet comes out root-owned and would lock the unprivileged CLI (and + web server) out of its own state. + """ + with contextlib.suppress(OSError): + owner = CONFIG_DIR.stat() + if STATE_FILE.stat().st_uid != owner.st_uid: + os.chown(STATE_FILE, owner.st_uid, owner.st_gid) + + +def op_store_unblock(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """Open the timed store window (1 to 30 minutes), as ``run.sh unblock`` does.""" + minutes = int_arg(args, "minutes", 1, MAX_WINDOW_MINUTES) + require_phrase("unblock", args.get("phrase"), minutes=minutes) + with hold_tick(ctx): + check_locks("unblock") + try: + until = open_store_window(State.load(), minutes) + except RuntimeError as exc: + raise CtlError(OP_FAILED, str(exc)) from exc + finally: + _hand_back_state_file() + logger.warning("Store window opened for %d min (control socket)", minutes) + return {"minutes": minutes, "until": until.isoformat()} + + +def op_block_gaming(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """Start a total gaming block of *days* days. There is no undo.""" + days = int_arg(args, "days", 1, MAX_BLOCK_DAYS) + require_phrase("block-gaming", args.get("phrase"), days=days) + with hold_tick(ctx): + check_locks("block-gaming") + logger.warning("Total gaming block for %d day(s) requested (socket)", days) + if not start_total_block(days): + raise CtlError(OP_FAILED, "failed to engage the block (see the log)") + until = get_total_block_status().until + return {"days": days, "until": until.isoformat() if until else None} + + +def op_gaming_unblock(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """Release every playtime mount (the recovery hatch).""" + require_phrase("gaming-unblock", args.get("phrase")) + with hold_tick(ctx): + check_locks("gaming-unblock") + released = release_block() + return {"released": [str(path) for path in released]} + + +OPS: Final[dict[str, Callable[[CtlContext, dict[str, object]], dict[str, object]]]] = { + "ping": op_ping, + "status": op_status, + "journal_tail": op_journal_tail, + "store_unblock": op_store_unblock, + "block_gaming": op_block_gaming, + "gaming_unblock": op_gaming_unblock, + **RESET_OPS, + **RESTART_OPS, +} diff --git a/steam_backlog_enforcer/_ctl_pending.py b/steam_backlog_enforcer/_ctl_pending.py new file mode 100644 index 0000000..c503fe2 --- /dev/null +++ b/steam_backlog_enforcer/_ctl_pending.py @@ -0,0 +1,182 @@ +"""The two-phase gaming reset: arm, keep alive for 300 s, then commit. + +The friction a typed phrase cannot give is *time*. Arming starts a countdown; +the caller must prove it is still there by heartbeating at least every +``HEARTBEAT_INTERVAL_SECONDS`` (the daemon tolerates ``LAPSE_AFTER_SECONDS`` +of silence); only after ``ready_at`` may the reset commit. A closed tab, a dead +server or a crashed browser therefore cancels the reset instead of leaving it +armed for whoever shows up next. + +Held in daemon memory on purpose: a daemon restart drops every pending reset, +which fails closed -- the caller just arms again and waits the full countdown. +Intervals use the monotonic clock so a wall-clock step cannot shorten the wait. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +import os +import secrets +import threading +import time +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._ctl_protocol import ( + COUNTDOWN_RUNNING, + PENDING_LAPSED, + CtlError, +) +from steam_backlog_enforcer._friction import GAMING_RESET_COUNTDOWN_SECONDS + +if TYPE_CHECKING: + from collections.abc import Callable + +HEARTBEAT_INTERVAL_SECONDS: Final = 10 +LAPSE_AFTER_SECONDS: Final = 15 + +# TEST ONLY. Lets the disposable-VM verification run the flow in seconds. It can +# only SHORTEN the countdown (1..299 s), never lengthen it, and an unset or +# invalid value means the real 300 s. It lives in the root-owned unit's +# environment; the unprivileged caller cannot set it. +TEST_COUNTDOWN_ENV: Final = "SBE_CTL_TEST_COUNTDOWN_SECONDS" + + +def countdown_seconds() -> int: + """The countdown to enforce: 300 s unless the VM-only override shortens it.""" + raw = os.environ.get(TEST_COUNTDOWN_ENV, "") + if raw.isdecimal() and 0 < int(raw) < GAMING_RESET_COUNTDOWN_SECONDS: + return int(raw) + return GAMING_RESET_COUNTDOWN_SECONDS + + +def _same(left: str, right: str) -> bool: + """Constant-time id comparison (bytes, so a non-ASCII id cannot raise).""" + return secrets.compare_digest(left.encode(), right.encode()) + + +@dataclass(frozen=True) +class Pending: + """One armed reset. + + Attributes: + pending_id: Unguessable handle the caller presents on every later call. + armed_at: Wall-clock arm time, for display. + ready_at: Wall-clock time the commit becomes allowed, for display. + ready_mono: Monotonic deadline the commit is actually checked against. + last_beat_mono: Monotonic time of the latest heartbeat (arm counts). + """ + + pending_id: str + armed_at: datetime + ready_at: datetime + ready_mono: float + last_beat_mono: float + + +class PendingResets: + """At most one armed reset, with its countdown and heartbeat bookkeeping.""" + + def __init__( + self, + clock: Callable[[], float] = time.monotonic, + wall: Callable[[], datetime] = lambda: datetime.now(UTC), + ) -> None: + """Start with nothing armed. + + Args: + clock: Monotonic seconds source (injectable for tests). + wall: Wall-clock source for the display timestamps. + """ + self._clock = clock + self._wall = wall + self._lock = threading.Lock() + self._current: Pending | None = None + + def arm(self) -> Pending: + """Arm a reset, or return the live one without restarting its countdown. + + Returning the existing one (rather than refusing or restarting) means a + second arm -- a reloaded page, a retry -- can neither shorten nor + extend the wait. + """ + with self._lock: + live = self._live() + if live is not None: + return live + now = self._clock() + wall = self._wall() + wait = countdown_seconds() + self._current = Pending( + pending_id=secrets.token_hex(16), + armed_at=wall, + ready_at=wall + timedelta(seconds=wait), + ready_mono=now + wait, + last_beat_mono=now, + ) + return self._current + + def beat(self, pending_id: str) -> Pending: + """Record a heartbeat. + + Raises: + CtlError: ``pending_lapsed`` if the id is unknown or already lapsed. + """ + with self._lock: + current = self._require(pending_id) + self._current = Pending( + current.pending_id, + current.armed_at, + current.ready_at, + current.ready_mono, + self._clock(), + ) + return self._current + + def check_ready(self, pending_id: str) -> Pending: + """Return the pending reset if it may commit now. + + Raises: + CtlError: ``pending_lapsed`` if unknown or lapsed, or + ``countdown_running`` (with ``retry_after_seconds``) if early. + """ + with self._lock: + current = self._require(pending_id) + remaining = current.ready_mono - self._clock() + if remaining > 0: + raise CtlError( + COUNTDOWN_RUNNING, + f"the countdown has {remaining:.0f} s left", + {"retry_after_seconds": round(remaining, 1)}, + ) + return current + + def finish(self, pending_id: str) -> None: + """Drop the pending reset after it committed or was cancelled. + + Idempotent: an id that is not (or no longer) armed is not an error. + """ + with self._lock: + current = self._current + if current is not None and _same(current.pending_id, pending_id): + self._current = None + + def _live(self) -> Pending | None: + """The armed reset, or ``None`` -- dropping it first if it has lapsed.""" + current = self._current + if current is not None and self._clock() - current.last_beat_mono > ( + LAPSE_AFTER_SECONDS + ): + self._current = None + return None + return current + + def _require(self, pending_id: str) -> Pending: + """The live reset for *pending_id*; the lock must be held.""" + current = self._live() + if current is None or not _same(current.pending_id, pending_id): + raise CtlError( + PENDING_LAPSED, + "no such pending reset, or it lapsed: arm it again", + ) + return current diff --git a/steam_backlog_enforcer/_ctl_protocol.py b/steam_backlog_enforcer/_ctl_protocol.py new file mode 100644 index 0000000..4a114d2 --- /dev/null +++ b/steam_backlog_enforcer/_ctl_protocol.py @@ -0,0 +1,95 @@ +"""Wire format shared by the daemon's control socket and its client. + +One JSON object per line in, one per line out, then the connection closes. +Kept free of every enforcer import so the unprivileged web server can load the +client without dragging the root daemon's modules (and their side effects) in. + +Error codes are the contract's (``DOCS-web-control-api.md``) plus two that only +the daemon can raise: ``op_failed`` (the privileged action ran and failed) and +``unsupported`` (the daemon is not supervised, so a restart would be a stop). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Final + +SOCKET_PATH: Final = Path("/run/steam-backlog-enforcer/ctl.sock") + +# A request is a few short fields; anything bigger is a mistake or an attack. +MAX_REQUEST_BYTES: Final = 4096 +# The longest legitimate response is journal_tail at its cap. +MAX_RESPONSE_BYTES: Final = 1024 * 1024 + +# Error codes. +WRONG_PHRASE: Final = "wrong_phrase" +INVALID_PARAMS: Final = "invalid_params" +UNKNOWN_OP: Final = "unknown_command" +LOCKED: Final = "locked" +BUSY: Final = "busy" +COUNTDOWN_RUNNING: Final = "countdown_running" +PENDING_LAPSED: Final = "pending_lapsed" +RATE_LIMITED: Final = "rate_limited" +OP_FAILED: Final = "op_failed" +UNSUPPORTED: Final = "unsupported" + + +class CtlError(Exception): + """A request the daemon refused or could not carry out. + + Attributes: + code: Machine-readable error code (see the constants above). + message: Human-readable text, safe to show in the UI. + data: Extra fields for the caller (e.g. ``retry_after_seconds``). + """ + + def __init__( + self, code: str, message: str, data: dict[str, object] | None = None + ) -> None: + """Build the error. + + Args: + code: Error code. + message: Human-readable text. + data: Optional extra fields carried alongside the error. + """ + super().__init__(f"{code}: {message}") + self.code = code + self.message = message + self.data = data or {} + + +class DaemonUnreachableError(Exception): + """The control socket is missing, refused us, or closed without answering.""" + + +def encode_line(obj: dict[str, object]) -> bytes: + """Serialise *obj* as one newline-terminated JSON line.""" + return json.dumps(obj, separators=(",", ":")).encode("utf-8") + b"\n" + + +def decode_line(raw: bytes) -> object: + """Parse one JSON line. + + Raises: + ValueError: If *raw* is not valid UTF-8 JSON. + """ + return json.loads(raw.decode("utf-8")) + + +def ok_response(data: dict[str, object]) -> bytes: + """The success line for *data*.""" + return encode_line({"ok": True, "data": data}) + + +def error_response(error: CtlError) -> bytes: + """The failure line for *error*.""" + body: dict[str, object] = { + "ok": False, + "error": error.code, + "message": error.message, + } + if error.data: + body["data"] = error.data + return encode_line(body) diff --git a/steam_backlog_enforcer/_ctl_reset.py b/steam_backlog_enforcer/_ctl_reset.py new file mode 100644 index 0000000..35cad4a --- /dev/null +++ b/steam_backlog_enforcer/_ctl_reset.py @@ -0,0 +1,104 @@ +"""The two-phase gaming reset as control-socket ops. + +``gaming_reset_arm`` -> (``gaming_reset_heartbeat`` every <= 10 s for the whole +countdown) -> ``gaming_reset_commit``. The phrase is checked at arm *and* +again at commit, the countdown and heartbeat bookkeeping live in +:mod:`steam_backlog_enforcer._ctl_pending`, and the reset itself is the same +core the CLI runs (:func:`steam_backlog_enforcer._gaming_reset.reset_today`). +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._ctl_context import ( + check_locks, + hold_tick, + require_phrase, + str_arg, +) +from steam_backlog_enforcer._ctl_pending import ( + HEARTBEAT_INTERVAL_SECONDS, + LAPSE_AFTER_SECONDS, +) +from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError +from steam_backlog_enforcer._friction import expected_phrase +from steam_backlog_enforcer._gaming_reset import reset_today + +if TYPE_CHECKING: + from steam_backlog_enforcer._ctl_context import CtlContext + from steam_backlog_enforcer._ctl_pending import Pending + +logger = logging.getLogger(__name__) + +_COMMAND: Final = "gaming-reset" + + +def _view(pending: Pending) -> dict[str, object]: + """The wire form of a pending reset (the contract's ``PendingAction``).""" + return { + "pending_id": pending.pending_id, + "phrase": expected_phrase(_COMMAND), + "armed_at": pending.armed_at.isoformat(), + "ready_at": pending.ready_at.isoformat(), + "heartbeat_interval_seconds": HEARTBEAT_INTERVAL_SECONDS, + "lapse_after_seconds": LAPSE_AFTER_SECONDS, + } + + +def op_arm(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """Start the countdown (or return the one already running).""" + require_phrase(_COMMAND, args.get("phrase")) + check_locks(_COMMAND) + pending = ctx.pending.arm() + logger.warning("Gaming reset armed; ready at %s", pending.ready_at.isoformat()) + return _view(pending) + + +def op_heartbeat(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """Keep the armed reset alive.""" + return _view(ctx.pending.beat(str_arg(args, "pending_id"))) + + +def op_commit(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """Run the reset, if the countdown finished and the phrase still matches.""" + pending_id = str_arg(args, "pending_id") + ctx.pending.check_ready(pending_id) + require_phrase(_COMMAND, args.get("phrase")) + with hold_tick(ctx): + # Authoritative re-check under the lock: of two racing commits only + # the first finds the pending reset still there. + ctx.pending.check_ready(pending_id) + check_locks(_COMMAND) + try: + outcome = reset_today(source="ctl") + except OSError as exc: + logger.exception("Gaming reset failed") + raise CtlError(OP_FAILED, f"the reset failed: {exc}") from exc + ctx.pending.finish(pending_id) + logger.warning( + "Gaming reset committed: %.0f s billed before, %d mount(s) released", + outcome.seconds_before, + len(outcome.released), + ) + return { + "day_key": outcome.day_key, + "seconds_before": round(outcome.seconds_before, 3), + "was_blocked": outcome.was_blocked, + "released": [str(path) for path in outcome.released], + } + + +def op_cancel(ctx: CtlContext, args: dict[str, object]) -> dict[str, object]: + """Abandon the armed reset (idempotent).""" + ctx.pending.finish(str_arg(args, "pending_id")) + return {"cancelled": True} + + +RESET_OPS: Final = { + "gaming_reset_arm": op_arm, + "gaming_reset_heartbeat": op_heartbeat, + "gaming_reset_commit": op_commit, + "gaming_reset_cancel": op_cancel, +} diff --git a/steam_backlog_enforcer/_ctl_restart.py b/steam_backlog_enforcer/_ctl_restart.py new file mode 100644 index 0000000..589f16d --- /dev/null +++ b/steam_backlog_enforcer/_ctl_restart.py @@ -0,0 +1,109 @@ +"""The ``restart`` op: the only way the socket ever ends the daemon process. + +There is deliberately no stop. A restart asks the enforce loop to finish the +pass it is in, bill the time up to now, and exit; ``Restart=always`` in the +unit brings it straight back. Because that makes a restart cost a few seconds +of downtime (and a fresh startup pass), it is rate limited to one per ten +minutes, and the limit is persisted in a root-owned file so restarting the +daemon cannot reset it. + +Safety analysis (what a restart does and does not lose, and how the downtime is +billed) is in ``DOCS-ctl-socket.md``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import json +import logging +from pathlib import Path +import threading +import time +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._ctl_protocol import ( + OP_FAILED, + RATE_LIMITED, + UNSUPPORTED, + CtlError, +) +from steam_backlog_enforcer.config import _atomic_write + +if TYPE_CHECKING: + from steam_backlog_enforcer._ctl_context import CtlContext + +logger = logging.getLogger(__name__) + +RESTART_INTERVAL_SECONDS: Final = 600 +# Created by StateDirectory=steam-backlog-enforcer in the unit. +RESTART_STATE_FILE: Final = Path("/var/lib/steam-backlog-enforcer/ctl_restart.json") +_STATE_MODE: Final = 0o600 + +# Serialises concurrent restart requests so two cannot both pass the check. +_restart_lock = threading.Lock() + + +def _last_restart() -> float: + """Epoch seconds of the last accepted restart; ``0.0`` if none or unreadable.""" + try: + raw = json.loads(RESTART_STATE_FILE.read_text(encoding="utf-8")) + value = raw["last_restart_at"] + except OSError, ValueError, KeyError, TypeError: + return 0.0 + return float(value) if isinstance(value, int | float) else 0.0 + + +def seconds_until_available() -> float: + """Seconds before another restart is allowed; ``0`` if one is allowed now. + + A recorded time in the future (clock stepped backwards) counts as "now", so + the wait can never exceed the interval. + """ + elapsed = max(0.0, time.time() - _last_restart()) + return max(0.0, RESTART_INTERVAL_SECONDS - elapsed) + + +def restart_available_at() -> str | None: + """ISO time a restart becomes allowed again, or ``None`` if allowed now.""" + wait = seconds_until_available() + if wait <= 0: + return None + return (datetime.now(UTC) + timedelta(seconds=wait)).isoformat() + + +def op_restart(ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]: + """Ask the daemon to restart itself (flush, exit, systemd brings it back). + + Raises: + CtlError: ``unsupported`` when systemd is not supervising the process + (the exit would be a stop), ``rate_limited`` inside the interval, + ``op_failed`` if the limit could not be persisted (fail closed). + """ + if not ctx.supervised: + raise CtlError( + UNSUPPORTED, + "the daemon is not running under systemd; a restart would be a stop", + ) + with _restart_lock: + wait = seconds_until_available() + if wait > 0: + raise CtlError( + RATE_LIMITED, + f"restart allowed again in {wait:.0f} s", + {"retry_after_seconds": round(wait, 1)}, + ) + try: + _atomic_write( + RESTART_STATE_FILE, + json.dumps({"last_restart_at": time.time()}) + "\n", + mode=_STATE_MODE, + ) + except OSError as exc: + logger.exception("Cannot persist the restart rate limit") + raise CtlError(OP_FAILED, "cannot record the restart; refusing") from exc + logger.warning("Restart requested over the control socket.") + ctx.restart_event.set() + return {"restarting": True, "restart_available_at": restart_available_at()} + + +RESTART_OPS: Final = {"restart": op_restart} diff --git a/steam_backlog_enforcer/_ctl_server.py b/steam_backlog_enforcer/_ctl_server.py new file mode 100644 index 0000000..6006904 --- /dev/null +++ b/steam_backlog_enforcer/_ctl_server.py @@ -0,0 +1,204 @@ +"""The control socket server: who may talk to the daemon, and how. + +A Unix socket at ``/run/steam-backlog-enforcer/ctl.sock``, mode 0660, group = +the desktop user's primary group. Filesystem permissions are the first gate; +``SO_PEERCRED`` is the second and the one that counts: the kernel reports the +connecting process's real uid, and anything but the desktop user is closed +without a byte of reply. + +Root is admitted too. Root can already do everything the ops do (``sudo +./run.sh gaming-reset``, ``guardctl``), so refusing it would protect nothing; +admitting it lets recovery and tests use the same interface. + +The server must never be the thing that takes the enforcer down or wedges it: +every connection has a read deadline and a size cap, concurrency is bounded, +each connection runs on its own thread, and no exception escapes a handler. +""" + +from __future__ import annotations + +import logging +import os +import socket +import threading +import time +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._ctl_ops import OPS +from steam_backlog_enforcer._ctl_protocol import ( + INVALID_PARAMS, + MAX_REQUEST_BYTES, + OP_FAILED, + SOCKET_PATH, + UNKNOWN_OP, + CtlError, + error_response, + ok_response, +) +from steam_backlog_enforcer._ctl_wire import ( + parse_request, + peer_uid, + read_line, + socket_is_live, +) + +if TYPE_CHECKING: + from pathlib import Path + + from steam_backlog_enforcer._ctl_context import CtlContext + +logger = logging.getLogger(__name__) + +SOCKET_MODE: Final = 0o660 +WRITE_TIMEOUT_SECONDS: Final = 5.0 +MAX_CONNECTIONS: Final = 8 +_BACKLOG: Final = 8 +_ACCEPT_POLL_SECONDS: Final = 1.0 +_ROOT_UID: Final = 0 + + +class CtlServer: + """Accepts, authenticates and dispatches control-socket connections.""" + + def __init__( + self, + ctx: CtlContext, + *, + desktop_uid: int, + desktop_gid: int, + path: Path = SOCKET_PATH, + ) -> None: + """Prepare a server; nothing is bound until :meth:`start`. + + Args: + ctx: Shared op context. + desktop_uid: The one non-root uid allowed to connect. + desktop_gid: Group that owns the socket file. + path: Socket path. + """ + self._ctx = ctx + self._allowed = frozenset({desktop_uid, _ROOT_UID}) + self._gid = desktop_gid + self._path = path + self._stop = threading.Event() + self._slots = threading.BoundedSemaphore(MAX_CONNECTIONS) + self._sock: socket.socket | None = None + + def start(self) -> bool: + """Bind the socket and start accepting. Never raises. + + Returns: + Whether the server is now listening. + """ + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + if socket_is_live(self._path): + logger.error( + "Control socket %s is already served; not taking over", self._path + ) + return False + self._path.unlink(missing_ok=True) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.bind(str(self._path)) + # Between bind() and here the file is root-owned 0755: nobody else + # has write permission, so nobody else can connect yet. + os.chown(self._path, 0, self._gid) + self._path.chmod(SOCKET_MODE) + sock.listen(_BACKLOG) + sock.settimeout(_ACCEPT_POLL_SECONDS) + except OSError: + logger.exception("Cannot start the control socket at %s", self._path) + return False + self._sock = sock + threading.Thread(target=self._serve, name="ctl-accept", daemon=True).start() + logger.info("Control socket listening on %s", self._path) + return True + + def stop(self) -> None: + """Stop accepting and remove the socket file.""" + self._stop.set() + if self._sock is not None: + self._sock.close() + self._sock = None + self._path.unlink(missing_ok=True) + + def _serve(self) -> None: + """Accept loop; survives anything but being told to stop.""" + while not self._stop.is_set(): + sock = self._sock + if sock is None: + return + try: + conn, _ = sock.accept() + except TimeoutError: + continue + except OSError: + if self._stop.is_set(): + return + logger.exception("Control socket accept failed") + time.sleep(_ACCEPT_POLL_SECONDS) + continue + self._admit(conn) + + def _admit(self, conn: socket.socket) -> None: + """Authenticate *conn* and hand it to a worker, or close it silently.""" + try: + uid = peer_uid(conn) + except OSError: + conn.close() + return + if uid not in self._allowed: + logger.warning("Control socket: refused a connection from uid %d", uid) + conn.close() + return + if not self._slots.acquire(blocking=False): + logger.warning("Control socket: too many connections, dropping one") + conn.close() + return + threading.Thread( + target=self._handle, args=(conn, uid), name="ctl-conn", daemon=True + ).start() + + def _handle(self, conn: socket.socket, uid: int) -> None: + """Serve one connection, always releasing its slot and socket.""" + try: + line = read_line(conn) + if line is not None: + self._write(conn, self._dispatch(line, uid)) + except Exception: + logger.debug("Control connection dropped", exc_info=True) + finally: + conn.close() + self._slots.release() + + @staticmethod + def _write(conn: socket.socket, payload: bytes) -> None: + """Send the response within the write deadline.""" + conn.settimeout(WRITE_TIMEOUT_SECONDS) + conn.sendall(payload) + + def _dispatch(self, line: bytes, uid: int) -> bytes: + """Turn a request line into a response line. Never raises.""" + try: + return ok_response(self._run(line, uid)) + except CtlError as exc: + logger.info("Control socket: refused (%s)", exc.code) + return error_response(exc) + except Exception: + logger.exception("Control socket op crashed") + return error_response(CtlError(OP_FAILED, "internal error")) + + def _run(self, line: bytes, uid: int) -> dict[str, object]: + """Validate a request line and run its op. + + Raises: + CtlError: For a malformed request, an unknown op, or a refused op. + """ + if len(line) > MAX_REQUEST_BYTES: + raise CtlError(INVALID_PARAMS, "request too large") + op, args = parse_request(line) + handler = OPS.get(op) + if handler is None: + raise CtlError(UNKNOWN_OP, f"unknown op {op!r}") + logger.info("Control socket: op=%s uid=%d", op, uid) + return handler(self._ctx, args) diff --git a/steam_backlog_enforcer/_ctl_types.py b/steam_backlog_enforcer/_ctl_types.py new file mode 100644 index 0000000..dbfd193 --- /dev/null +++ b/steam_backlog_enforcer/_ctl_types.py @@ -0,0 +1,56 @@ +"""Result types returned by :mod:`steam_backlog_enforcer._ctl_client`. + +Plain frozen dataclasses so the web server maps them onto its contract types +without ever handling the daemon's raw JSON. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DaemonInfo: + """The daemon's own account of itself (``status``).""" + + state: str + started_at: str + pid: int + restart_available_at: str | None + + +@dataclass(frozen=True) +class StoreWindow: + """An opened store window.""" + + minutes: int + until: str + + +@dataclass(frozen=True) +class BlockResult: + """A started total gaming block.""" + + days: int + until: str | None + + +@dataclass(frozen=True) +class PendingReset: + """An armed two-phase gaming reset (maps onto the contract's PendingAction).""" + + pending_id: str + phrase: str + armed_at: str + ready_at: str + heartbeat_interval_seconds: int + + +@dataclass(frozen=True) +class ResetResult: + """A committed gaming reset.""" + + day_key: str + seconds_before: float + was_blocked: bool + released: list[str] diff --git a/steam_backlog_enforcer/_ctl_wire.py b/steam_backlog_enforcer/_ctl_wire.py new file mode 100644 index 0000000..1f29b8e --- /dev/null +++ b/steam_backlog_enforcer/_ctl_wire.py @@ -0,0 +1,89 @@ +"""Server-side wire helpers for the control socket. + +Everything that touches the raw connection before an op runs: who is on the +other end, whether anything already serves the path, and reading/validating one +request line under a deadline and a size cap. +""" + +from __future__ import annotations + +import socket +import struct +import time +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._ctl_protocol import ( + INVALID_PARAMS, + MAX_REQUEST_BYTES, + CtlError, + decode_line, +) + +if TYPE_CHECKING: + from pathlib import Path + +READ_TIMEOUT_SECONDS: Final = 5.0 +_PEERCRED: Final = struct.Struct("3i") # struct ucred: pid, uid, gid + + +def peer_uid(conn: socket.socket) -> int: + """The kernel-reported uid of the process on the other end of *conn*.""" + raw = conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, _PEERCRED.size) + return int(_PEERCRED.unpack(raw)[1]) + + +def socket_is_live(path: Path) -> bool: + """Whether something is already accepting connections on *path*.""" + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + probe.settimeout(1.0) + try: + probe.connect(str(path)) + except OSError: + return False + finally: + probe.close() + return True + + +def read_line(conn: socket.socket) -> bytes | None: + """Read one request line within the deadline and size cap. + + Returns: + The line, or ``None`` if the client sent nothing or stalled. + """ + deadline = time.monotonic() + READ_TIMEOUT_SECONDS + data = b"" + while b"\n" not in data: + remaining = deadline - time.monotonic() + if remaining <= 0: + return None + conn.settimeout(remaining) + try: + chunk = conn.recv(1024) + except TimeoutError: + return None + if not chunk: + break + data += chunk + if len(data) > MAX_REQUEST_BYTES: + return data[: MAX_REQUEST_BYTES + 1] + return data.split(b"\n", 1)[0] if data else None + + +def parse_request(line: bytes) -> tuple[str, dict[str, object]]: + """Decode a request into ``(op, args)``. + + Raises: + CtlError: ``invalid_params`` for anything that is not + ``{"op": str, "args": object?}``. + """ + try: + request = decode_line(line) + except ValueError as exc: + raise CtlError(INVALID_PARAMS, "request is not valid JSON") from exc + if not isinstance(request, dict) or not isinstance(request.get("op"), str): + raise CtlError(INVALID_PARAMS, 'request must be {"op": "", ...}') + args = request.get("args", {}) + if not isinstance(args, dict): + raise CtlError(INVALID_PARAMS, "args must be an object") + return request["op"], args diff --git a/steam_backlog_enforcer/_echo.py b/steam_backlog_enforcer/_echo.py index 00f061c..9d4920b 100644 --- a/steam_backlog_enforcer/_echo.py +++ b/steam_backlog_enforcer/_echo.py @@ -1,26 +1,55 @@ """User-facing CLI output. -A one-function leaf module so every command can print without importing the -install machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to -keep that module under the 250-line cap. +A leaf module so every command can print without importing the install +machinery. Split out of :mod:`steam_backlog_enforcer.game_install` to keep +that module under the 250-line cap. + +Output normally goes to stdout. A web job installs a sink with +:func:`routed_echo`, and every ``_echo`` inside it becomes a ``log`` event +instead: the commands keep one way of talking, whoever is listening. """ from __future__ import annotations +from contextlib import contextmanager +from contextvars import ContextVar import sys +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + +# Receives the raw text (message + end) when set; stdout otherwise. +_SINK: ContextVar[Callable[[str], None] | None] = ContextVar("echo_sink", default=None) def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None: - """Write user-facing CLI output to stdout. + """Write user-facing CLI output to stdout, or to the active sink. Args: msg: Text to output. end: String appended after the message. flush: Whether to flush stdout immediately. """ + sink = _SINK.get() + if sink is not None: + sink(msg + end) + return sys.stdout.write(msg + end) if flush: sys.stdout.flush() -# Steam infrastructure app IDs that should NEVER be uninstalled. +@contextmanager +def routed_echo(sink: Callable[[str], None]) -> Iterator[None]: + """Send every ``_echo`` in this context to *sink* instead of stdout. + + Args: + sink: Called with the raw text, ``end`` included, so it can treat + carriage returns and newlines the way a terminal would. + """ + token = _SINK.set(sink) + try: + yield + finally: + _SINK.reset(token) diff --git a/steam_backlog_enforcer/_enforce_guards.py b/steam_backlog_enforcer/_enforce_guards.py new file mode 100644 index 0000000..8928442 --- /dev/null +++ b/steam_backlog_enforcer/_enforce_guards.py @@ -0,0 +1,68 @@ +"""Per-pass guards of the enforce loop: who may stay installed, and who is named. + +Split out of :mod:`steam_backlog_enforcer._enforce_loop` to keep it under the +250-line cap once it also had to drive the control socket. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._actions import allowed_games +from steam_backlog_enforcer.enforcer import send_notification +from steam_backlog_enforcer.game_uninstall import ( + get_installed_games, + is_protected_app, + uninstall_game, +) + +if TYPE_CHECKING: + from steam_backlog_enforcer.config import State + +logger = logging.getLogger(__name__) + + +def allowed_names(state: State) -> str: + """Return a human-readable list of the games the user may play. + + Args: + state: Current enforcer state. + + Returns: + Comma-separated game names, or "your assigned game" when none is set. + """ + names = [name for _, name in allowed_games(state) if name] + return ", ".join(names) if names else "your assigned game" + + +def guard_installed_games(allowed: set[int]) -> int: + """Remove any unauthorized game manifests + files. Runs every loop. + + Args: + allowed: Every app id that may stay installed — the assignment plus + any concurrent manual picks. + + Returns number of games removed this pass. + """ + if not allowed: + return 0 + installed = get_installed_games() + count = 0 + for app_id, name in installed: + if app_id in allowed: + continue + if is_protected_app(app_id): + continue + + logger.warning( + "Unauthorized game detected — removing: %s (AppID=%d)", name, app_id + ) + if uninstall_game(app_id, name): + count += 1 + send_notification( + "Game Removed!", + f"Uninstalled {name} (AppID={app_id}). " + f"Only your assigned game(s) are allowed.", + ) + return count diff --git a/steam_backlog_enforcer/_enforce_loop.py b/steam_backlog_enforcer/_enforce_loop.py index 1f34eac..62223e5 100644 --- a/steam_backlog_enforcer/_enforce_loop.py +++ b/steam_backlog_enforcer/_enforce_loop.py @@ -5,8 +5,11 @@ from __future__ import annotations import json import logging -from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games +from steam_backlog_enforcer._actions import allowed_app_ids +from steam_backlog_enforcer._ctl_control import start_control +from steam_backlog_enforcer._ctl_gap import settle_restart_gap from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._enforce_guards import allowed_names, guard_installed_games from steam_backlog_enforcer._enforce_steps import ( _enforce_setup, _reinstall_missing_allowed, @@ -40,11 +43,6 @@ from steam_backlog_enforcer.enforcer import ( enforce_allowed_game, send_notification, ) -from steam_backlog_enforcer.game_uninstall import ( - get_installed_games, - is_protected_app, - uninstall_game, -) logger = logging.getLogger(__name__) @@ -58,49 +56,32 @@ __all__ = [ ] -def _allowed_names(state: State) -> str: - """Return a human-readable list of the games the user may play. +def _reload_state(state: State) -> bool: + """Refresh *state* in place from disk, so CLI changes take effect at once. + + Covers e.g. a new game assignment via ``done`` / ``scan`` without needing + to restart the daemon. Args: - state: Current enforcer state. + state: The daemon's state object, updated in place. Returns: - Comma-separated game names, or "your assigned game" when none is set. + False (after logging) if the file could not be read; *state* is then + left as it was. """ - names = [name for _, name in allowed_games(state) if name] - return ", ".join(names) if names else "your assigned game" - - -def _guard_installed_games(allowed: set[int]) -> int: - """Remove any unauthorized game manifests + files. Runs every loop. - - Args: - allowed: Every app id that may stay installed — the assignment plus - any concurrent manual picks. - - Returns number of games removed this pass. - """ - if not allowed: - return 0 - installed = get_installed_games() - count = 0 - for app_id, name in installed: - if app_id in allowed: - continue - if is_protected_app(app_id): - continue - - logger.warning( - "Unauthorized game detected — removing: %s (AppID=%d)", name, app_id - ) - if uninstall_game(app_id, name): - count += 1 - send_notification( - "Game Removed!", - f"Uninstalled {name} (AppID={app_id}). " - f"Only your assigned game(s) are allowed.", - ) - return count + try: + fresh = State.load() + except (json.JSONDecodeError, OSError, ValueError) as exc: + logger.warning("Failed to reload state: %s", exc) + return False + # Every field, not a hand-picked few: the daemon's pick sweep saves this + # object, and any field left stale here (cooldowns, pick release times, + # assignment times) would overwrite what the CLI or the MCP server wrote. + # The MCP pick_manual tool also adds a *second* pick without touching + # current_app_id, which a daemon that never reloaded manual_picks would + # uninstall. + vars(state).update(vars(fresh)) + return True def _enforce_loop_iteration( @@ -164,12 +145,12 @@ def _enforce_loop_iteration( send_notification( "Game Blocked!", f"Killed unauthorized game (AppID={app_id}). " - f"Focus on {_allowed_names(state)}!", + f"Focus on {allowed_names(state)}!", ) # B) Remove any newly-installed unauthorized games. if config.uninstall_other_games: - removed = _guard_installed_games(allowed) + removed = guard_installed_games(allowed) if removed > 0: _echo(f" Guard removed {removed} unauthorized game(s)") @@ -225,26 +206,28 @@ def do_enforce(config: Config, state: State, *, demo: bool = False) -> None: session = new_session(demo=demo) # A credited workout wakes the wait, so the budget moves within the tick. wake = open_file_wake([workout_log_path(config)]) + # Root-only control socket for the web UI. Its ops take tick_lock, so they + # never interleave with a pass; inert (no socket) when not the real daemon. + control = start_control(config, demo=demo) + with control.tick_lock: + settle_restart_gap(config, session, base_interval=ENFORCE_INTERVAL, demo=demo) try: while True: - # Reload state from disk so CLI changes (e.g. new game - # assignment via ``done`` / ``scan``) take effect immediately - # without needing to restart the daemon. - try: - fresh = State.load() - except (json.JSONDecodeError, OSError, ValueError) as exc: - logger.warning("Failed to reload state: %s", exc) - wake.wait(ENFORCE_INTERVAL) - continue - # Every field, not a hand-picked few: the daemon's pick sweep saves - # this object, and any field left stale here (cooldowns, pick - # release times, assignment times) would overwrite what the CLI - # or the MCP server wrote. The MCP pick_manual tool also adds a - # *second* pick without touching current_app_id, which a daemon - # that never reloaded manual_picks would uninstall. - vars(state).update(vars(fresh)) - - _enforce_loop_iteration(config, state, session=session, demo=demo) + with control.tick_lock: + # Reloaded under the lock: a control-socket op that saved + # state a moment ago must not be overwritten by a copy read + # before it ran. + if _reload_state(state): + _enforce_loop_iteration(config, state, session=session, demo=demo) + leave = control.flush_for_restart( + config, session, interval=ENFORCE_INTERVAL, demo=demo + ) + if leave: + # Exit cleanly: Restart=always brings the unit straight back. + _echo("Restart requested: state flushed, exiting.") + return wake.wait(ENFORCE_INTERVAL) except KeyboardInterrupt: _echo("\nEnforcer stopped.") + finally: + control.close() diff --git a/steam_backlog_enforcer/_friction.py b/steam_backlog_enforcer/_friction.py new file mode 100644 index 0000000..f5ce2a2 --- /dev/null +++ b/steam_backlog_enforcer/_friction.py @@ -0,0 +1,68 @@ +"""Typed-phrase friction shared by the CLI, the web job runner and the daemon. + +One table, so a phrase cannot be strict in one front end and lax in another. +The phrase is checked wherever the action is actually carried out (the job +subprocess or the root daemon), never only in the browser: a check that +``curl`` can skip is decoration, not friction. + +Templates use ``str.format`` fields; the caller supplies every field the +template names (``days``, ``minutes``, ``game_name``, ``count``, ``backup_id``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True) +class Friction: + """The friction attached to one command. + + Attributes: + phrase_template: Text the user must type, with ``{field}`` slots. + countdown_seconds: Armed wait with heartbeats before commit (0 = none). + """ + + phrase_template: str + countdown_seconds: int = 0 + + +GAMING_RESET_COUNTDOWN_SECONDS: Final = 300 + +FRICTION: Final[dict[str, Friction]] = { + "gaming-reset": Friction( + "reset today's gaming budget", GAMING_RESET_COUNTDOWN_SECONDS + ), + "block-gaming": Friction("block all gaming for {days} days"), + "unblock": Friction("unblock the store for {minutes} minutes"), + "buy-dlc": Friction("unblock the store for {minutes} minutes"), + "gaming-unblock": Friction("force release playtime mounts"), + "abandon-pick": Friction("abandon {game_name}"), + "pick-manual": Friction("lock in {game_name}"), + "add-exception": Friction("request exception for {game_name}"), + "uninstall": Friction("uninstall {count} games"), + "reset": Friction("wipe all enforcer state"), + "restore-backup": Friction("restore backup {backup_id}"), +} + + +def expected_phrase(command: str, **fields: object) -> str | None: + """Return the exact phrase *command* requires, or ``None`` if it has none. + + Raises: + KeyError: If the template names a field missing from *fields*. + """ + friction = FRICTION.get(command) + if friction is None: + return None + return friction.phrase_template.format(**fields) + + +def phrase_matches(expected: str, typed: str) -> bool: + """Compare a typed phrase to the expected one. + + Only surrounding whitespace is forgiven. Case and inner spacing must + match: retyping the sentence exactly is the point. + """ + return typed.strip() == expected diff --git a/steam_backlog_enforcer/_gaming_reset.py b/steam_backlog_enforcer/_gaming_reset.py new file mode 100644 index 0000000..f41b9d8 --- /dev/null +++ b/steam_backlog_enforcer/_gaming_reset.py @@ -0,0 +1,123 @@ +"""The gaming-reset core: zero today's counter, lift the block, leave a record. + +Shared by the CLI (``sudo ./run.sh gaming-reset``, the direct recovery path) +and the daemon's two-phase control-socket op, so both do exactly the same +thing and both leave the same trail. The caller owns the confirmation +(``YES`` on the CLI, a phrase plus a heartbeat countdown on the socket). + +Needs root: it releases bind mounts and writes the immutable state file. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from datetime import UTC, datetime +import logging +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._gaming_days import gaming_day_key +from steam_backlog_enforcer._playtime_block import release_block +from steam_backlog_enforcer._playtime_budget import roll_over +from steam_backlog_enforcer._playtime_history import record_day +from steam_backlog_enforcer._playtime_log import ( + EVENT_MANUAL_ADJUSTMENT, + BudgetLog, + budget_log_path, +) +from steam_backlog_enforcer._playtime_state import ( + PlaytimeState, + load_state, + save_state, +) + +if TYPE_CHECKING: + from pathlib import Path + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ResetOutcome: + """What a gaming reset changed. + + Attributes: + day_key: The gaming day that was reset. + seconds_before: Seconds billed before the reset. + was_blocked: Whether the cutoff had engaged. + released: Launcher mounts that were released. + """ + + day_key: str + seconds_before: float + was_blocked: bool + released: list[Path] + + +def reset_today(*, source: str) -> ResetOutcome: + """Zero today's billed time, drop the block and record the adjustment. + + The earned-time bookkeeping (``carry`` from earlier free days, the day's + ``budget_seconds`` high-water mark) is kept: resetting spent time must not + also revoke time already earned. Warnings re-arm because ``seconds`` is + back at zero. + + The reset is written to the budget audit log as a ``manual_adjustment`` + (with what was billed before, per game); that record is the auditable + trail. The per-day history is also set to 0 -- it mirrors *current* billed + time, so it shows the day as quiet and the audit log is what says why. + + Args: + source: Who asked (``"cli"`` or ``"ctl"``), stored in the audit record. + + Returns: + What changed. + """ + now = datetime.now(UTC).astimezone() + day_key = gaming_day_key(now) + stored = load_state(demo=False) + before = roll_over(stored, day_key=day_key) if stored else PlaytimeState() + released = release_block() + save_state( + replace( + before, + day_key=day_key, + seconds=0.0, + blocked_at=0.0, + last_tick_at=now.timestamp(), + per_game={}, + last_credited_key="", + warned_seconds=[], + ), + demo=False, + ) + _record(source, day_key, before, released) + return ResetOutcome( + day_key=day_key, + seconds_before=before.seconds, + was_blocked=before.is_blocked(), + released=released, + ) + + +def _record( + source: str, day_key: str, before: PlaytimeState, released: list[Path] +) -> None: + """Write the audit record and the history point; never raises.""" + log = BudgetLog(path=budget_log_path(demo=False)) + log.record( + EVENT_MANUAL_ADJUSTMENT, + kind="gaming_reset", + source=source, + day_key=day_key, + billed_seconds_before=round(before.seconds, 3), + billed_seconds_after=0.0, + per_game_before=before.per_game, + was_blocked=before.is_blocked(), + released=[str(path) for path in released], + ) + log.close() + try: + record_day(day_key, 0.0, {}) + except OSError as exc: + # A history point is cosmetic; the reset itself already happened. + logger.warning("Could not record the reset in the history: %s", exc) diff --git a/steam_backlog_enforcer/_hltb_cached.py b/steam_backlog_enforcer/_hltb_cached.py index a817c61..d3813b8 100644 --- a/steam_backlog_enforcer/_hltb_cached.py +++ b/steam_backlog_enforcer/_hltb_cached.py @@ -18,11 +18,36 @@ from steam_backlog_enforcer._hltb_types import ( load_hltb_rush_cache, save_hltb_cache, ) +from steam_backlog_enforcer._progress import current_progress from steam_backlog_enforcer.hltb import fetch_hltb_times_timed, games_per_second logger = logging.getLogger(__name__) +def reporting_progress_cb( + label: str, total: int, inner: ProgressCb | None +) -> ProgressCb: + """Start a progress phase and return a callback that advances it. + + The returned callback forwards to *inner* (the CLI's carriage-return meter), so + the terminal output is unchanged while a web job gets real steps. + + Args: + label: Phase label shown in the UI. + total: Number of lookups in the phase. + inner: The caller's own callback, if any. + """ + progress = current_progress() + progress.phase(label, total) + + def report(done: int, total: int, found: int, name: str) -> None: + progress.advance(name, step=done) + if inner is not None: + inner(done, total, found, name) + + return report + + # ────────────────────────────────────────────────────────────── # Confidence-only batch fetch (no leisure/DLC detail pages) # ────────────────────────────────────────────────────────────── @@ -55,7 +80,10 @@ def fetch_hltb_times_cached( len(uncached), len(games) - len(uncached), ) - elapsed = fetch_hltb_times_timed(uncached, cache, polls, progress_cb, extras) + report = reporting_progress_cb( + "Fetching HLTB times", len(uncached), progress_cb + ) + elapsed = fetch_hltb_times_timed(uncached, cache, polls, report, extras) # Final save. save_hltb_cache(cache, polls, extras) diff --git a/steam_backlog_enforcer/_hltb_confidence.py b/steam_backlog_enforcer/_hltb_confidence.py index 610c42c..3d35939 100644 --- a/steam_backlog_enforcer/_hltb_confidence.py +++ b/steam_backlog_enforcer/_hltb_confidence.py @@ -9,6 +9,7 @@ from __future__ import annotations import logging import time +from steam_backlog_enforcer._hltb_cached import reporting_progress_cb from steam_backlog_enforcer._hltb_types import ( HLTB_BASE_URL, ProgressCb, @@ -82,7 +83,9 @@ def fetch_hltb_confidence_cached( uncached, cache=cache, polls=polls, - progress_cb=progress_cb, + progress_cb=reporting_progress_cb( + "Fetching HLTB poll counts", len(uncached), progress_cb + ), count_comp=count_comp, ) elapsed = time.monotonic() - t0 @@ -169,7 +172,8 @@ def fetch_hltb_detail_missing( ) else: logger.info("Backfilling HLTB game ID for %d game(s)...", n_id) - elapsed = fetch_hltb_times_timed(missing, cache, polls, progress_cb, extras) + report = reporting_progress_cb("Fetching HLTB details", len(missing), progress_cb) + elapsed = fetch_hltb_times_timed(missing, cache, polls, report, extras) restore_prior_hours(cache, prior_hours) diff --git a/steam_backlog_enforcer/_hltb_detail.py b/steam_backlog_enforcer/_hltb_detail.py index ebc3b39..bbfdf3d 100644 --- a/steam_backlog_enforcer/_hltb_detail.py +++ b/steam_backlog_enforcer/_hltb_detail.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import logging -import re from typing import Any import aiohttp @@ -27,13 +26,10 @@ from steam_backlog_enforcer._hltb_types import ( _HLTBExtras, save_hltb_cache, ) +from steam_backlog_enforcer._progress import gather_tracked logger = logging.getLogger(__name__) -_NEXT_DATA_RE = re.compile( - r'', -) - def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float: """Extract base-game leisure hours from game detail data. @@ -183,7 +179,8 @@ async def _fetch_leisure_times( connector=connector, ) as session: coros = [_fetch_detail_one(sem, session, r.hltb_game_id) for r in valid] - details = await asyncio.gather(*coros) + names = [r.game_name for r in valid] + details = await gather_tracked("Fetching HLTB detail pages", coros, names) dlc_relationships_by_app, dlc_ids = _collect_dlc_relationships(valid, details) dlc_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids) @@ -237,7 +234,7 @@ async def _fetch_dlc_leisure_hours( return {} coros = [_fetch_detail_one(sem, session, dlc_id) for dlc_id in dlc_ids] - dlc_details = await asyncio.gather(*coros) + dlc_details = await gather_tracked("Fetching HLTB DLC pages", coros) dlc_hours_by_id: dict[int, float] = {} for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False): diff --git a/steam_backlog_enforcer/_hltb_search.py b/steam_backlog_enforcer/_hltb_search.py index 204ae4d..e83a488 100644 --- a/steam_backlog_enforcer/_hltb_search.py +++ b/steam_backlog_enforcer/_hltb_search.py @@ -22,6 +22,7 @@ from steam_backlog_enforcer._hltb_search_api import ( _get_auth_info, _get_hltb_search_url, _pick_best_hltb_entry, + forget_hltb_search_url, ) from steam_backlog_enforcer._hltb_types import ( _SAVE_INTERVAL, @@ -168,6 +169,7 @@ async def _search_batch( auth = await _get_auth_info(search_url, init_session) if auth is None: logger.warning("Could not get HLTB auth info, aborting fetch.") + forget_hltb_search_url() return [] logger.info("HLTB auth token acquired.") diff --git a/steam_backlog_enforcer/_hltb_search_api.py b/steam_backlog_enforcer/_hltb_search_api.py index 227bbea..d1673fa 100644 --- a/steam_backlog_enforcer/_hltb_search_api.py +++ b/steam_backlog_enforcer/_hltb_search_api.py @@ -45,7 +45,15 @@ _EXTENDED_MIN_CONFIDENCE = 3 _USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" -def _get_hltb_search_url() -> str: +_DEFAULT_SEARCH_URL = "https://howlongtobeat.com/api/finder" +# Discovery scrapes the homepage and its JS bundles (~0.8 s), and one command +# can run several HLTB batches (``stats`` runs three), so a discovered URL is +# reused for a while. Failures are never remembered: they retry next time. +_SEARCH_URL_TTL_SECONDS = 1800.0 +_search_url_memo: dict[str, tuple[float, str]] = {} + + +def _discover_hltb_search_url() -> str: """Discover the current HLTB search API endpoint. Scrapes the homepage for JS bundles containing the fetch URL, walking @@ -60,7 +68,24 @@ def _get_hltb_search_url() -> str: return url except OSError, RuntimeError, ValueError, TypeError: logger.debug("Failed to discover HLTB search URL, using default") - return "https://howlongtobeat.com/api/finder" + return _DEFAULT_SEARCH_URL + + +def _get_hltb_search_url() -> str: + """Return the HLTB search endpoint, rediscovering it once it is stale.""" + now = time.monotonic() + memo = _search_url_memo.get("url") + if memo is not None and now - memo[0] < _SEARCH_URL_TTL_SECONDS: + return memo[1] + url = _discover_hltb_search_url() + if url != _DEFAULT_SEARCH_URL: + _search_url_memo["url"] = (now, url) + return url + + +def forget_hltb_search_url() -> None: + """Drop the remembered endpoint, e.g. after it stopped handing out tokens.""" + _search_url_memo.clear() async def _get_auth_info( diff --git a/steam_backlog_enforcer/_own_pick.py b/steam_backlog_enforcer/_own_pick.py new file mode 100644 index 0000000..d64b605 --- /dev/null +++ b/steam_backlog_enforcer/_own_pick.py @@ -0,0 +1,131 @@ +"""Let the user name their own next game instead of taking one from the list. + +The ranked list only offers games that clear the HLTB-confidence and ProtonDB +filters. Choosing your own game is a plain assignment, exactly as if it had +been on the list (no manual-pick lock): any owned game that is not 100 % +complete qualifies, confidence or not. Games still in a skip/abandon cooldown +stay out, so abandoning and re-picking cannot become a loop. + +:func:`ineligible_reason` is the one rule: the web library greys games with +it, and a job only accepts an answer from the set it leaves eligible. A +front end that can browse the library (:class:`GamePicker`) gets that set; +the CLI keeps its text search. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._prompter import ( + GamePicker, + PromptOption, + current_prompter, +) +from steam_backlog_enforcer.game_install import _echo + +if TYPE_CHECKING: + from steam_backlog_enforcer.config import State + from steam_backlog_enforcer.steam_api import GameInfo + +OWN_PICK_VALUE: Final = "own" +OWN_PICK_OPTION: Final = PromptOption( + value=OWN_PICK_VALUE, + label="Pick my own game…", + detail="Any owned game you have not 100%-completed", +) + +# More matches than this and the user is asked to narrow the search. +_MAX_MATCHES: Final = 15 +_SEARCH_AGAIN: Final = "search-again" + + +def ineligible_reason( + app_id: int, game: GameInfo | None, state: State, skipped: set[int] +) -> str | None: + """Why *app_id* cannot be picked as your own game, or ``None`` if it can. + + Args: + app_id: The owned game. + game: Its scan data; ``None`` when the scan has none (no + achievements, or not scanned yet), so it could never complete. + state: The enforcer state. + skipped: ``state.active_skipped_ids()``, computed once by the caller. + """ + if game is None: + return "No achievement data" + if game.is_complete: + return "Already 100% complete" + if app_id in state.finished_app_ids: + # Older states marked games finished below 100 %; still not pickable. + return "Marked finished" + if app_id in skipped: + until = state.skipped_until.get(str(app_id), "")[:10] + return f"Skipped until {until}" if until else "Skipped for now" + return None + + +def _eligible(games: list[GameInfo], state: State) -> list[GameInfo]: + """Owned games the user may pick: unfinished and not cooling down.""" + skipped = state.active_skipped_ids() + return [g for g in games if ineligible_reason(g.app_id, g, state, skipped) is None] + + +def _matches(query: str, games: list[GameInfo]) -> list[GameInfo]: + """Exact app_id for a number, else case-insensitive name substring.""" + if query.isdigit(): + return [g for g in games if g.app_id == int(query)] + needle = query.casefold() + hits = [g for g in games if needle in g.name.casefold()] + return sorted( + hits, key=lambda g: (not g.name.casefold().startswith(needle), g.name) + ) + + +def _label(game: GameInfo) -> PromptOption: + """One match as a choice option.""" + detail = f"{game.completion_pct:.0f}% achievements" + if game.completionist_hours > 0: + detail += f", ~{game.completionist_hours:.1f}h" + return PromptOption( + value=str(game.app_id), + label=f"{game.name} (AppID={game.app_id})", + detail=detail, + ) + + +def _choose_among(hits: list[GameInfo]) -> GameInfo | None: + """Ask which of several matches was meant; ``None`` means search again.""" + options = [_label(g) for g in hits] + options.append(PromptOption(value=_SEARCH_AGAIN, label="Search again")) + picked = current_prompter().choice("Which game?", options) + if picked == _SEARCH_AGAIN: + return None + return next(g for g in hits if str(g.app_id) == picked) + + +def prompt_own_pick(games: list[GameInfo], state: State) -> GameInfo | None: + """Search the user's own games until one is chosen. + + Returns: + The chosen game, or ``None`` when the user leaves the search empty + to go back to the previous question. + """ + eligible = _eligible(games, state) + prompter = current_prompter() + if isinstance(prompter, GamePicker): + picked = prompter.game("Pick your next game", [g.app_id for g in eligible]) + return next((g for g in eligible if g.app_id == picked), None) + while True: + query = current_prompter().text("Game name or app_id (leave empty to go back)") + if not query: + return None + hits = _matches(query, eligible) + if not hits: + _echo(f"No unfinished owned game matches {query!r}.") + continue + if len(hits) > _MAX_MATCHES: + _echo(f"{len(hits)} games match {query!r}; type more of the name.") + continue + chosen = hits[0] if len(hits) == 1 else _choose_among(hits) + if chosen is not None: + return chosen diff --git a/steam_backlog_enforcer/_owned_apps_cache.py b/steam_backlog_enforcer/_owned_apps_cache.py index 61d490e..dc738fc 100644 --- a/steam_backlog_enforcer/_owned_apps_cache.py +++ b/steam_backlog_enforcer/_owned_apps_cache.py @@ -15,7 +15,7 @@ from typing import Any from steam_backlog_enforcer._snapshot import load_snapshot from steam_backlog_enforcer.config import CONFIG_DIR, Config, _atomic_write -from steam_backlog_enforcer.steam_api import SteamAPIClient +from steam_backlog_enforcer.steam_api import SteamAPIClient, SteamAPIError logger = logging.getLogger(__name__) @@ -51,16 +51,80 @@ def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None: return [int(app_id) for app_id in raw_ids] -def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None: - """Persist owned app IDs cache for this steam_id.""" - payload = { +def _owned_record(game: dict[str, Any]) -> dict[str, Any]: + """The part of a ``GetOwnedGames`` entry the web library shows.""" + return { + "app_id": int(game["appid"]), + "name": str(game.get("name") or ""), + "playtime_minutes": int(game.get("playtime_forever") or 0), + "last_played": int(game.get("rtime_last_played") or 0), + # False: the game has no achievements at all (vs. not scanned yet). + "has_stats": bool(game.get("has_community_visible_stats")), + } + + +def _save_owned_app_ids_cache( + steam_id: str, app_ids: list[int], games: list[dict[str, Any]] | None = None +) -> None: + """Persist owned app IDs cache for this steam_id. + + *games* (the raw API entries) adds the ``games`` records the library + view reads; ``app_ids`` stays as it was for the enforce loop. + """ + payload: dict[str, Any] = { "steam_id": steam_id, "fetched_at": time.time(), "app_ids": app_ids, } + if games is not None: + payload["games"] = [_owned_record(g) for g in games if "appid" in g] _atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n") +def owned_cache_stamp() -> tuple[int, int]: + """``(mtime_ns, size)`` of the cache file; ``(0, 0)`` when absent.""" + try: + st = _OWNED_IDS_CACHE_FILE.stat() + except OSError: + return (0, 0) + return (st.st_mtime_ns, st.st_size) + + +def load_owned_records(steam_id: str) -> list[dict[str, Any]] | None: + """Cached per-game records (name, playtime, last played), any age. + + Names and play history barely change between refreshes, so the TTL that + guards the id list does not apply. ``None`` when no refresh has stored + records for this account yet. + """ + try: + data = json.loads(_OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8")) + except json.JSONDecodeError, OSError, ValueError: + return None + if not isinstance(data, dict) or str(data.get("steam_id", "")) != steam_id: + return None + games = data.get("games") + return games if isinstance(games, list) else None + + +def refresh_owned_records(config: Config) -> list[dict[str, Any]] | None: + """Fetch the owned list from Steam and cache it with records. + + Returns: + The records, or ``None`` when Steam could not be asked. + """ + if not (config.steam_api_key and config.steam_id): + return None + try: + owned = SteamAPIClient(config.steam_api_key, config.steam_id).get_owned_games() + except OSError, SteamAPIError, ValueError: + logger.warning("Could not fetch the owned game list.") + return None + api_ids = [int(g["appid"]) for g in owned if "appid" in g] + _save_owned_app_ids_cache(config.steam_id, api_ids, owned) + return [_owned_record(g) for g in owned if "appid" in g] + + # ────────────────────────────────────────────────────────────── # Helpers # ────────────────────────────────────────────────────────────── @@ -92,7 +156,7 @@ def get_all_owned_app_ids(config: Config) -> list[int]: client = SteamAPIClient(config.steam_api_key, config.steam_id) owned = client.get_owned_games() api_ids = [int(g["appid"]) for g in owned if "appid" in g] - _save_owned_app_ids_cache(config.steam_id, api_ids) + _save_owned_app_ids_cache(config.steam_id, api_ids, owned) merged_ids: list[int] = [] seen: set[int] = set() diff --git a/steam_backlog_enforcer/_progress.py b/steam_backlog_enforcer/_progress.py new file mode 100644 index 0000000..9544bc9 --- /dev/null +++ b/steam_backlog_enforcer/_progress.py @@ -0,0 +1,125 @@ +"""Progress reporting for long operations, shared by the CLI and web jobs. + +Long loops (library scan, HLTB lookups, per-game uninstall, tampering check) +report *what* they are doing through :func:`current_progress`; *who* listens +is decided by the caller. The CLI listens to nothing — it already prints its +own meters with ``_echo``, and they stay byte-identical — while a web job +installs a reporter that writes ``progress`` events. + +The active reporter lives in a context variable rather than a parameter so +the loops, which sit several calls below the command entry points, report +without every signature in between growing an argument. +""" + +from __future__ import annotations + +import asyncio +from contextlib import contextmanager +from contextvars import ContextVar +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Iterator, Sequence + + +class Progress(Protocol): + """Receives the phases and steps of a long operation.""" + + def phase(self, label: str, total: int | None = None) -> None: + """Start a new phase; ``total`` is ``None`` when it is indeterminate.""" + + def advance(self, item: str | None = None, *, step: int | None = None) -> None: + """Move one step forward, or to ``step`` when the caller counts itself. + + Args: + item: What the step was about (a game name), shown next to it. + step: Absolute step number; ``None`` means "one more than before". + """ + + +class NullProgress: + """The CLI reporter: does nothing, the CLI prints its own meters.""" + + def phase(self, label: str, total: int | None = None) -> None: + """Ignore a phase start.""" + del label, total + + def advance(self, item: str | None = None, *, step: int | None = None) -> None: + """Ignore a step.""" + del item, step + + +_ACTIVE: ContextVar[Progress | None] = ContextVar("progress", default=None) +_NULL = NullProgress() + + +def current_progress() -> Progress: + """Return the reporter the running command should talk to.""" + return _ACTIVE.get() or _NULL + + +@contextmanager +def use_progress(progress: Progress) -> Iterator[Progress]: + """Make *progress* the active reporter for the duration of the context.""" + token = _ACTIVE.set(progress) + try: + yield progress + finally: + _ACTIVE.reset(token) + + +def tracked[T](items: Sequence[T], label: str, name: Callable[[T], str]) -> Iterator[T]: + """Yield *items*, reporting one determinate step per item. + + The step is reported *after* the caller finishes with the item, so the + count shown is always "done", never "started". + + Args: + items: The work list; its length is the phase total. + label: Phase label, e.g. ``"Uninstalling games"``. + name: Turns an item into the text shown next to its step. + """ + progress = current_progress() + progress.phase(label, len(items)) + for item in items: + yield item + progress.advance(name(item)) + + +async def gather_tracked[T]( + label: str, coros: Sequence[Awaitable[T]], names: Sequence[str] | None = None +) -> list[T]: + """``asyncio.gather`` that reports one determinate step per finished awaitable. + + Steps count completions (in whatever order they finish), so the bar moves + while the batch runs instead of jumping to full at the end. + + Args: + label: Phase label, e.g. ``"Fetching HLTB detail pages"``. + coros: The awaitables; their count is the phase total. + names: Per-awaitable text shown next to its step, if any. + """ + progress = current_progress() + progress.phase(label, len(coros)) + done = 0 + + async def one(coro: Awaitable[T], name: str | None) -> T: + nonlocal done + result = await coro + done += 1 + progress.advance(name, step=done) + return result + + labels = names if names is not None else [None] * len(coros) + return list(await asyncio.gather(*map(one, coros, labels, strict=True))) + + +def eta_seconds(elapsed: float, step: int, total: int | None) -> int | None: + """Estimate the seconds left from the average time per finished step. + + Returns: + ``None`` until at least one step finished or when there is no total. + """ + if total is None or step <= 0: + return None + return max(0, round(elapsed / step * (total - step))) diff --git a/steam_backlog_enforcer/_prompter.py b/steam_backlog_enforcer/_prompter.py new file mode 100644 index 0000000..0455bb9 --- /dev/null +++ b/steam_backlog_enforcer/_prompter.py @@ -0,0 +1,168 @@ +"""Questions a command asks its user, answered by a terminal or the web UI. + +Every ``input()`` in the package goes through :func:`current_prompter`. The +CLI prompter reads stdin exactly as the commands used to; a web job installs +one that emits a ``prompt`` event and waits for the answer the server writes +(see :mod:`steam_backlog_enforcer.jobs._job_prompter`). + +Typed-phrase confirmations take their text from +:func:`steam_backlog_enforcer._friction.expected_phrase`, so the CLI, the job +runner and the daemon all demand the same sentence. +""" + +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import dataclass +import sys +from typing import TYPE_CHECKING, Protocol, runtime_checkable + +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._friction import expected_phrase, phrase_matches + +if TYPE_CHECKING: + from collections.abc import Iterator, Sequence + + +@dataclass(frozen=True) +class PromptOption: + """One answer to a ``choice`` prompt. + + Attributes: + value: What the prompter returns when this option is chosen. + label: The text shown for it. + detail: Optional secondary text (the UI shows it under the label). + """ + + value: str + label: str + detail: str | None = None + + +class PromptAbortedError(Exception): + """The user declined, or never answered, a prompt the command needs.""" + + +class Prompter(Protocol): + """Asks the user something and returns the answer.""" + + @property + def interactive(self) -> bool: + """Whether a human can answer at all (a piped CLI cannot).""" + + def choice(self, message: str, options: Sequence[PromptOption]) -> str: + """Return the ``value`` of the option the user picked.""" + + def confirm(self, message: str, *, default: bool) -> bool: + """Return the user's yes/no answer.""" + + def phrase(self, message: str, expected: str) -> bool: + """Return whether the user typed *expected* exactly.""" + + def text(self, message: str) -> str: + """Return a line of free text, stripped.""" + + +@runtime_checkable +class GamePicker(Protocol): + """A prompter that can show the whole library to pick a game from. + + Optional on top of :class:`Prompter`: the CLI has no browser and keeps + its text search (:func:`steam_backlog_enforcer._own_pick.prompt_own_pick`). + """ + + def game(self, message: str, app_ids: Sequence[int]) -> int | None: + """Return the picked app id (one of *app_ids*), or ``None`` to go back.""" + + +class CliPrompter: + """Reads answers from stdin; the CLI's behaviour before prompters existed.""" + + @property + def interactive(self) -> bool: + """True when stdin is a terminal.""" + return sys.stdin.isatty() + + def choice(self, message: str, options: Sequence[PromptOption]) -> str: + """Print a numbered list and loop until a valid number is typed.""" + for i, option in enumerate(options, 1): + _echo(f" {i}. {option.label}") + while True: + raw = input(f"{message}: ") + try: + idx = int(raw) + except ValueError: + _echo(f"Invalid input: {raw!r}") + continue + if idx < 1 or idx > len(options): + _echo(f"Out of range: {idx}") + continue + return options[idx - 1].value + + def confirm(self, message: str, *, default: bool) -> bool: + """Ask ``[Y/n]``; an empty answer or end of input means *default*.""" + hint = "[Y/n]" if default else "[y/N]" + while True: + try: + answer = input(f"{message} {hint}: ").strip().lower() + except EOFError: + return default + if not answer: + return default + if answer in {"y", "yes"}: + return True + if answer in {"n", "no"}: + return False + _echo(" Please answer 'y' or 'n'.") + + def phrase(self, message: str, expected: str) -> bool: + """Ask for the phrase once; a mismatch is a "no". + + The CLI has already printed what is at stake before asking, so only + the phrase itself is shown; *message* is for front ends without that + preamble. + """ + del message + return phrase_matches(expected, input(f'Type "{expected}" to confirm: ')) + + def text(self, message: str) -> str: + """Ask for one line of text.""" + return input(f"{message}: ").strip() + + +_ACTIVE: ContextVar[Prompter | None] = ContextVar("prompter", default=None) +_CLI = CliPrompter() + + +def current_prompter() -> Prompter: + """Return the prompter the running command should ask.""" + return _ACTIVE.get() or _CLI + + +@contextmanager +def use_prompter(prompter: Prompter) -> Iterator[Prompter]: + """Make *prompter* the active one for the duration of the context.""" + token = _ACTIVE.set(prompter) + try: + yield prompter + finally: + _ACTIVE.reset(token) + + +def confirm_phrase(command: str, message: str, **fields: object) -> bool: + """Ask for *command*'s friction phrase and say whether it was typed. + + Args: + command: Key into :data:`steam_backlog_enforcer._friction.FRICTION`. + message: What is being confirmed, for front ends that show it. + **fields: The template's slots (``game_name``, ``count``, ...). + + Raises: + KeyError: If *command* has no phrase; a missing phrase must never + silently mean "confirmed". + """ + expected = expected_phrase(command, **fields) + if expected is None: + raise KeyError(command) + return current_prompter().phrase(message, expected) diff --git a/steam_backlog_enforcer/_scanning_assign.py b/steam_backlog_enforcer/_scanning_assign.py index f247447..322f4d2 100644 --- a/steam_backlog_enforcer/_scanning_assign.py +++ b/steam_backlog_enforcer/_scanning_assign.py @@ -15,6 +15,12 @@ from steam_backlog_enforcer._assignment_progress import ( last_assigned_epoch, record_assignment, ) +from steam_backlog_enforcer._own_pick import ( + OWN_PICK_OPTION, + OWN_PICK_VALUE, + prompt_own_pick, +) +from steam_backlog_enforcer._prompter import PromptOption, current_prompter from steam_backlog_enforcer._scanning_candidates import ( _pick_next_shortest_candidate, _sort_key, @@ -45,24 +51,28 @@ _NO_CONF_MSG = ( ) -def _prompt_user_pick(qualified: list[GameInfo]) -> int: - """Present numbered list, return 0-based index of user's choice.""" - for i, g in enumerate(qualified, 1): +def _prompt_user_pick( + qualified: list[GameInfo], games: list[GameInfo], state: State +) -> GameInfo: + """Present the ranked list plus "pick my own"; return the chosen game. + + Backing out of the own-game search (empty answer) shows the list again. + """ + options: list[PromptOption] = [] + for i, g in enumerate(qualified): hours_str = ( f" (~{g.completionist_hours:.1f}h)" if g.completionist_hours > 0 else "" ) - _echo(f" {i}. {g.name} (AppID={g.app_id}){hours_str}") + label = f"{g.name} (AppID={g.app_id}){hours_str}" + options.append(PromptOption(value=str(i), label=label)) + options.append(OWN_PICK_OPTION) while True: - raw = input("Select game number: ") - try: - idx = int(raw) - except ValueError: - _echo(f"Invalid input: {raw!r}") - continue - if idx < 1 or idx > len(qualified): - _echo(f"Out of range: {idx}") - continue - return idx - 1 + picked = current_prompter().choice("Select game number", options) + if picked != OWN_PICK_VALUE: + return qualified[int(picked)] + own = prompt_own_pick(games, state) + if own is not None: + return own def _assign_chosen_game( @@ -145,13 +155,14 @@ def _pick_next_game_sequential( games: list[GameInfo], state: State, config: Config, - on_select: Callable[[GameInfo], bool], + on_select: Callable[[GameInfo], bool | GameInfo], ) -> None: """Pick the next-shortest playable game, asking the user per candidate. ``on_select`` is called with each prospective pick. Returning ``True`` accepts the assignment; returning ``False`` records a 7-day skip on - ``state`` for that game and the next candidate is evaluated. + ``state`` for that game and the next candidate is evaluated; returning a + game assigns that one instead (the user picked their own). """ while True: candidates = _open_candidates(games, state) @@ -168,11 +179,14 @@ def _pick_next_game_sequential( ) return - if not on_select(chosen): + verdict = on_select(chosen) + if verdict is False: state.skip_for_days(chosen.app_id, 7) state.save() _echo(f"\n Skipped {chosen.name} for 7 days; picking next...") continue - _assign_chosen_game(chosen, games, state, config) + _assign_chosen_game( + chosen if verdict is True else verdict, games, state, config + ) return diff --git a/steam_backlog_enforcer/_scanning_tampering.py b/steam_backlog_enforcer/_scanning_tampering.py index 18b707d..382a282 100644 --- a/steam_backlog_enforcer/_scanning_tampering.py +++ b/steam_backlog_enforcer/_scanning_tampering.py @@ -6,23 +6,34 @@ the 250-line cap. Leaf helpers: nothing here calls back into ``scanning``. from __future__ import annotations +from collections import deque +from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import closing +from itertools import islice import logging from typing import TYPE_CHECKING, Any from steam_backlog_enforcer._allowed_games import allowed_games from steam_backlog_enforcer._assignment_progress import iso_to_epoch +from steam_backlog_enforcer._progress import current_progress from steam_backlog_enforcer._snapshot import load_snapshot +from steam_backlog_enforcer._steam_models import MAX_WORKERS from steam_backlog_enforcer._whitelist_locking import get_approved_exception_ids from steam_backlog_enforcer.enforcer import send_notification from steam_backlog_enforcer.game_install import _echo from steam_backlog_enforcer.steam_api import SteamAPIClient if TYPE_CHECKING: + from collections.abc import Generator + from steam_backlog_enforcer.config import Config, State + from steam_backlog_enforcer.steam_api import GameInfo logger = logging.getLogger(__name__) _TAMPER_CHECK_LIMIT = 3 +# Requests kept in flight while re-fetching; same width as the library scan. +_TAMPER_WINDOW = MAX_WORKERS def _legitimately_played(state: State) -> set[int]: @@ -51,6 +62,51 @@ def _legitimately_played(state: State) -> set[int]: return exempt +def _needs_refetch(entry: dict[str, Any], exempt: set[int]) -> bool: + """Whether *entry* could show tampering, so is worth a Steam request. + + Args: + entry: Snapshot entry for the game. + exempt: App ids from :func:`_legitimately_played`. + """ + if entry["app_id"] in exempt: + return False + if entry["unlocked_achievements"] >= entry["total_achievements"]: + return False + playtime: int = entry.get("playtime_minutes", 0) + return playtime > 0 + + +def _unexpected_unlocks( + entry: dict[str, Any], game: GameInfo, state: State +) -> tuple[str, int, int] | None: + """Compare fresh achievement data with the snapshot entry. + + Returns: + Tuple of (name, app_id, diff) if tampering detected, else None. + """ + app_id = entry["app_id"] + released = iso_to_epoch(state.released_at.get(str(app_id), "")) + if released is not None: + # Released below 100%: the snapshot predates the unlocks earned while + # it was assigned, so only unlocks after the release are suspicious. + diff = sum( + 1 for a in game.achievements if a.achieved and a.unlock_time > released + ) + else: + diff = game.unlocked_achievements - entry["unlocked_achievements"] + if diff > 0: + return (entry["name"], app_id, diff) + return None + + +def _refetch(client: SteamAPIClient, entry: dict[str, Any]) -> GameInfo | None: + """Re-fetch one snapshot entry's achievements from Steam.""" + return client.refresh_single_game( + entry["app_id"], entry["name"], entry.get("playtime_minutes", 0) + ) + + def _check_game_tampering( client: SteamAPIClient, entry: dict[str, Any], @@ -66,30 +122,39 @@ def _check_game_tampering( Returns: Tuple of (name, app_id, diff) if tampering detected, else None. """ - app_id = entry["app_id"] - if app_id in _legitimately_played(state): + if not _needs_refetch(entry, _legitimately_played(state)): return None - if entry["unlocked_achievements"] >= entry["total_achievements"]: - return None - if entry.get("playtime_minutes", 0) <= 0: - return None - game = client.refresh_single_game( - app_id, entry["name"], entry.get("playtime_minutes", 0) - ) + game = _refetch(client, entry) if game is None: return None - released = iso_to_epoch(state.released_at.get(str(app_id), "")) - if released is not None: - # Released below 100%: the snapshot predates the unlocks earned while - # it was assigned, so only unlocks after the release are suspicious. - diff = sum( - 1 for a in game.achievements if a.achieved and a.unlock_time > released - ) - else: - diff = game.unlocked_achievements - entry["unlocked_achievements"] - if diff > 0: - return (entry["name"], app_id, diff) - return None + return _unexpected_unlocks(entry, game, state) + + +def _refetch_in_order( + client: SteamAPIClient, entries: list[dict[str, Any]] +) -> Generator[tuple[dict[str, Any], GameInfo | None]]: + """Yield ``(entry, fresh game)`` in *entries* order, fetching ahead. + + A sliding window keeps :data:`_TAMPER_WINDOW` requests in flight (the + client's own rate limiter still caps requests per second), so the caller + can stop early without having asked Steam about the whole library. Closing + the generator drops queued requests instead of waiting for them, which is + also what makes a cancelled job stop promptly. + """ + pool = ThreadPoolExecutor(max_workers=_TAMPER_WINDOW) + pending: deque[tuple[dict[str, Any], Future[GameInfo | None]]] = deque() + upcoming = iter(entries) + try: + for entry in islice(upcoming, _TAMPER_WINDOW): + pending.append((entry, pool.submit(_refetch, client, entry))) + while pending: + entry, future = pending.popleft() + nxt = next(upcoming, None) + if nxt is not None: + pending.append((nxt, pool.submit(_refetch, client, nxt))) + yield entry, future.result() + finally: + pool.shutdown(wait=False, cancel_futures=True) def detect_tampering(config: Config, state: State) -> None: @@ -99,15 +164,22 @@ def detect_tampering(config: Config, state: State) -> None: return client = SteamAPIClient(config.steam_api_key, config.steam_id) + exempt = _legitimately_played(state) + to_check = [e for e in old_snapshot if _needs_refetch(e, exempt)] - # Quick check: only re-fetch a few random non-assigned games. + # Stops at the first few hits, in snapshot order, like the serial loop did. suspicious: list[tuple[str, int, int]] = [] - for entry in old_snapshot: - result = _check_game_tampering(client, entry, state) - if result: - suspicious.append(result) - if len(suspicious) >= _TAMPER_CHECK_LIMIT: - break + progress = current_progress() + progress.phase("Checking other games for tampering", len(to_check)) + with closing(_refetch_in_order(client, to_check)) as fetched: + for entry, game in fetched: + progress.advance(str(entry.get("name", ""))) + result = None if game is None else _unexpected_unlocks(entry, game, state) + if result: + suspicious.append(result) + if len(suspicious) >= _TAMPER_CHECK_LIMIT: + progress.advance(step=len(to_check)) + break if suspicious: _echo("\n TAMPERING DETECTED:") diff --git a/steam_backlog_enforcer/_steam_api_client.py b/steam_backlog_enforcer/_steam_api_client.py index 62a2669..0d1c165 100644 --- a/steam_backlog_enforcer/_steam_api_client.py +++ b/steam_backlog_enforcer/_steam_api_client.py @@ -13,6 +13,12 @@ from typing import TYPE_CHECKING, Any import requests +from steam_backlog_enforcer._achievement_skip import ( + is_no_stats_answer, + record_scan, + skippable_app_ids, +) +from steam_backlog_enforcer._progress import current_progress from steam_backlog_enforcer._steam_models import ( MAX_WORKERS, STEAM_API_BASE, @@ -52,6 +58,8 @@ class SteamAPIClient: self._rate_lock = threading.Lock() self._request_times: list[float] = [] self._max_rps = 18 + # Games Steam answered "no stats" for during the current scan. + self._no_stats: set[int] = set() def _rate_limit(self) -> None: """Enforce rate limit across threads.""" @@ -95,8 +103,13 @@ class SteamAPIClient: logger.info("Found %d owned games.", len(games)) return games - def get_achievement_details(self, app_id: int) -> list[AchievementInfo]: - """Fetch per-achievement detail for a game.""" + def get_achievement_details( + self, app_id: int, *, strict: bool = False + ) -> list[AchievementInfo]: + """Fetch per-achievement detail for a game. + + ``strict``: only Steam's "no stats" answer is empty; failures raise. + """ url = f"{STEAM_API_BASE}/ISteamUserStats/GetPlayerAchievements/v1/" try: data = self._get( @@ -108,7 +121,9 @@ class SteamAPIClient: "format": "json", }, ) - except SteamAPIError: + except SteamAPIError as exc: + if strict and not is_no_stats_answer(exc): + raise return [] stats = data.get("playerstats", {}) @@ -130,8 +145,9 @@ class SteamAPIClient: """Fetch achievement data for one game. Thread-safe.""" app_id = game_dict["appid"] - achievements = self.get_achievement_details(app_id) + achievements = self.get_achievement_details(app_id, strict=True) if not achievements: + self._no_stats.add(app_id) # set.add is atomic under the GIL return None name = game_dict.get("name", f"Unknown ({app_id})") @@ -151,34 +167,68 @@ class SteamAPIClient: self, progress_callback: Callable[[int, int], None] | None = None, ) -> list[GameInfo]: - """Build full game list with achievement data (parallel).""" + """Build full game list with achievement data (parallel). + + Games recently found to have no achievements are not asked about + again (see :mod:`._achievement_skip`). + """ owned = self.get_owned_games() + now = time.time() + skip = skippable_app_ids(self.steam_id, owned, now) + to_fetch = [g for g in owned if g["appid"] not in skip] + if skip: + logger.info("Skipping %d games known to have no achievements.", len(skip)) + self._no_stats = set() + games = self._fetch_games(to_fetch, progress_callback) + # Only games Steam actually answered for: a timeout or a 5xx must not + # be remembered as "no achievements" and hidden from scans for a month. + found = {g.app_id for g in games} + answered = [g for g in to_fetch if g["appid"] in found | self._no_stats] + record_scan(self.steam_id, answered, found, now) + games.sort(key=lambda g: g.name.lower()) + return games + + def _fetch_games( + self, + owned: list[dict[str, Any]], + progress_callback: Callable[[int, int], None] | None, + ) -> list[GameInfo]: + """Fetch achievements for *owned* in parallel, reporting each game.""" games: list[GameInfo] = [] done_count = 0 total = len(owned) lock = threading.Lock() + progress = current_progress() + progress.phase("Scanning Steam achievements", total) with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: futures = {pool.submit(self._fetch_one_game, g): g for g in owned} - for future in as_completed(futures): - try: - result = future.result() - except ( - KeyError, - TypeError, - ValueError, - SteamAPIError, - requests.RequestException, - ): - result = None - with lock: - done_count += 1 - if progress_callback: - progress_callback(done_count, total) - if result is not None: - games.append(result) - - games.sort(key=lambda g: g.name.lower()) + try: + for future in as_completed(futures): + try: + result = future.result() + except ( + KeyError, + TypeError, + ValueError, + SteamAPIError, + requests.RequestException, + ): + result = None + with lock: + done_count += 1 + progress.advance( + str(futures[future].get("name", "")), step=done_count + ) + if progress_callback: + progress_callback(done_count, total) + if result is not None: + games.append(result) + except BaseException: + # A cancelled job must not wait for the whole queue to drain: + # drop what has not started, leaving only requests in flight. + pool.shutdown(wait=False, cancel_futures=True) + raise return games def refresh_single_game( diff --git a/steam_backlog_enforcer/_web_art.py b/steam_backlog_enforcer/_web_art.py new file mode 100644 index 0000000..f254038 --- /dev/null +++ b/steam_backlog_enforcer/_web_art.py @@ -0,0 +1,139 @@ +"""``GET /api/art/{app_id}``: a game's portrait cover for the library grid. + +Looked for in order: Steam's own librarycache (portrait files only; the +40-hex ``.jpg`` there are 32x32 icons and ``header.jpg`` is landscape), this +server's disk cache of earlier CDN fetches, then Steam's CDN. No cover is a +404 and the UI draws a title tile instead. + +The one view that may use the network, so it is fenced: owned app ids only +(the id is an int put into a fixed URL, never a proxy), a few fetches at a +time with a short timeout, and a failed fetch is remembered for a while so a +grid of 1000+ tiles does not re-ask a blocked or missing CDN on every +scroll. A 404 is remembered for a week; a network failure (the total block +lists ``steamstatic.com``) for minutes, so art appears once it lifts. +""" + +from __future__ import annotations + +import contextlib +from http import HTTPStatus +from pathlib import Path +import threading +import time +from typing import TYPE_CHECKING, Final + +import requests + +from steam_backlog_enforcer._web_errors import not_found +from steam_backlog_enforcer._web_io import Reply +from steam_backlog_enforcer._web_library import owned_app_ids + +if TYPE_CHECKING: + from steam_backlog_enforcer._web_io import Request + +LIBRARYCACHE = Path("~/.local/share/Steam/appcache/librarycache").expanduser() +ART_CACHE_DIR = Path.home() / ".cache" / "steam_backlog_enforcer" / "art" + +_CDN: Final = ( + "https://shared.steamstatic.com/store_item_assets/steam/apps/" + "{app_id}/library_600x900.jpg" +) +_PORTRAITS: Final = ("library_600x900.jpg", "library_capsule.jpg") +_NOT_FOUND_RETRY_S: Final = 7 * 24 * 3600 +_FAILED_RETRY_S: Final = 10 * 60 +_TIMEOUT: Final = (3.05, 6.0) +_MAX_BYTES: Final = 2 * 1024 * 1024 +_HEADERS: Final = {"Cache-Control": "private, max-age=86400"} +_fetch_slots = threading.BoundedSemaphore(6) + + +def _local_cover(app_id: int) -> Path | None: + """A portrait Steam itself cached for *app_id*, if any.""" + base = LIBRARYCACHE / str(app_id) + for name in _PORTRAITS: + for candidate in (base / name, *sorted(base.glob(f"*/{name}"))): + if candidate.is_file(): + return candidate + return None + + +def _cache_file(app_id: int) -> Path: + return ART_CACHE_DIR / f"{app_id}.jpg" + + +def _miss_file(app_id: int) -> Path: + return ART_CACHE_DIR / f"{app_id}.miss" + + +def _miss_active(app_id: int) -> bool: + """Whether a recent fetch failed and it is too early to retry.""" + try: + retry_at = float(_miss_file(app_id).read_text(encoding="utf-8")) + except OSError, ValueError: + return False + return time.time() < retry_at + + +def _record_miss(app_id: int, seconds: int) -> None: + ART_CACHE_DIR.mkdir(parents=True, exist_ok=True) + _miss_file(app_id).write_text(str(time.time() + seconds), encoding="utf-8") + + +def _store(app_id: int, body: bytes) -> None: + """Write the cover atomically so a reader never sees half a JPEG.""" + ART_CACHE_DIR.mkdir(parents=True, exist_ok=True) + tmp = ART_CACHE_DIR / f"{app_id}.jpg.{threading.get_ident()}.tmp" + tmp.write_bytes(body) + tmp.replace(_cache_file(app_id)) + with contextlib.suppress(OSError): + _miss_file(app_id).unlink() + + +def _fetch(app_id: int) -> bytes | None: + """Download the CDN cover into the cache; ``None`` when there is none.""" + with _fetch_slots: + # Another request may have fetched it while this one waited. + with contextlib.suppress(OSError): + return _cache_file(app_id).read_bytes() + if _miss_active(app_id): + return None + try: + resp = requests.get(_CDN.format(app_id=app_id), timeout=_TIMEOUT) + except requests.RequestException: + _record_miss(app_id, _FAILED_RETRY_S) + return None + ctype = resp.headers.get("Content-Type", "") + if resp.status_code != HTTPStatus.OK or not ctype.startswith("image/"): + missing = resp.status_code in {HTTPStatus.NOT_FOUND, HTTPStatus.FORBIDDEN} + _record_miss(app_id, _NOT_FOUND_RETRY_S if missing else _FAILED_RETRY_S) + return None + if len(resp.content) > _MAX_BYTES: + _record_miss(app_id, _NOT_FOUND_RETRY_S) + return None + _store(app_id, resp.content) + return resp.content + + +def cover_bytes(app_id: int) -> bytes | None: + """The cover for *app_id* from the first source that has one.""" + local = _local_cover(app_id) + if local is not None: + return local.read_bytes() + with contextlib.suppress(OSError): + return _cache_file(app_id).read_bytes() + if _miss_active(app_id): + return None + return _fetch(app_id) + + +def art_view(request: Request) -> Reply: + """``GET /api/art/{app_id}`` — JPEG bytes, or ``not_found``.""" + raw = request.args[0] + if not raw.isdigit() or int(raw) not in owned_app_ids(): + msg = f"{raw} is not an owned game." + raise not_found(msg) + body = cover_bytes(int(raw)) + if body is None: + msg = f"No cover art for {raw}." + raise not_found(msg) + return Reply(HTTPStatus.OK, raw=body, ctype="image/jpeg", headers=_HEADERS) diff --git a/steam_backlog_enforcer/_web_auth.py b/steam_backlog_enforcer/_web_auth.py new file mode 100644 index 0000000..3af0f09 --- /dev/null +++ b/steam_backlog_enforcer/_web_auth.py @@ -0,0 +1,129 @@ +"""Who may talk to the web server: Host, Origin and the per-launch token. + +The contract's "Web server auth" section, in one place: + +- ``Host`` must name this server on loopback (``127.0.0.1:`` or + ``localhost:``) on *every* request, static files included. That is + the DNS-rebinding guard: a page on ``evil.example`` re-pointed at + 127.0.0.1 still sends its own name as ``Host``, so it can read neither + the data nor the ``index.html`` that carries the token. +- Every non-GET must carry ``Origin`` equal to the origin it was served from. +- Every non-GET, and the SSE stream (``EventSource`` cannot set headers, so + it sends ``?token=``), must carry the per-launch token. + +The token is 32 random bytes, regenerated at every launch, written owner-only +to ``$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-.token`` (per port, so +a scratch server on another port never clobbers the live one's file) and +injected into the served ``index.html`` as ````. A page from a +previous launch therefore stops working when the server restarts: the UI +must reload on ``bad_token``. +""" + +from __future__ import annotations + +import hmac +import os +from pathlib import Path +import re +import secrets +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._web_errors import ApiError + +if TYPE_CHECKING: + from email.message import Message + +# Where the token travels. Names, not secrets; the UI's copies are +# ``TOKEN_HEADER`` / ``TOKEN_META`` in web/src/api/contract.ts. +AUTH_HEADER: Final = "X-SBE-Token" +AUTH_META: Final = "sbe-token" +AUTH_QUERY: Final = "token" +_RUNTIME_SUBDIR: Final = "steam-backlog-enforcer" +_LOOPBACK_NAMES: Final = ("127.0.0.1", "localhost") +_HEAD_TAG = re.compile(rb"]*>", re.IGNORECASE) + + +def new_token() -> str: + """A fresh per-launch token: 32 random bytes as hex.""" + return secrets.token_hex(32) + + +def token_path(port: int) -> Path: + """Where the token file of the server on *port* lives. + + ``$XDG_RUNTIME_DIR/steam-backlog-enforcer/web-.token`` (runtime dir + ``/run/user/`` when unset). The Vite dev server reads the same file. + """ + runtime = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}" + return Path(runtime) / _RUNTIME_SUBDIR / f"web-{port}.token" + + +def write_token_file(token: str, port: int) -> Path: + """Write *token* owner-only (dir 0700, file 0600) and return its path. + + The file is created with mode 0600 from the start, never chmod-ed after + a world-readable write, and replaced atomically so a reader never sees a + half-written token. + """ + path = token_path(port) + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + path.parent.chmod(0o700) + tmp = path.with_suffix(".tmp") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.fchmod(fd, 0o600) + os.write(fd, f"{token}\n".encode("ascii")) + finally: + os.close(fd) + tmp.replace(path) + return path + + +def allowed_hosts(port: int) -> frozenset[str]: + """The ``Host`` header values this server answers to.""" + return frozenset(f"{name}:{port}" for name in _LOOPBACK_NAMES) + + +def check_host(headers: Message, port: int) -> str: + """Return the validated ``Host`` header. + + Raises: + ApiError: ``bad_host`` for a missing or foreign host. + """ + host = (headers.get("Host") or "").strip().lower() + if host not in allowed_hosts(port): + msg = f"Host {host or '(none)'!r} is not this server." + raise ApiError(msg, code="bad_host") + return host + + +def check_origin(headers: Message, host: str) -> None: + """Require ``Origin`` to be the origin this request's page came from. + + Raises: + ApiError: ``bad_origin`` for a missing or different origin. + """ + origin = (headers.get("Origin") or "").strip().lower() + if origin != f"http://{host}": + msg = f"Origin {origin or '(none)'!r} may not change state here." + raise ApiError(msg, code="bad_origin") + + +def check_token(supplied: str | None, token: str) -> None: + """Compare a supplied token in constant time. + + Raises: + ApiError: ``bad_token`` for a missing or wrong token. + """ + if not supplied or not hmac.compare_digest(supplied.encode(), token.encode()): + msg = "Missing or stale token: reload the page." + raise ApiError(msg, code="bad_token") + + +def inject_token(html: bytes, token: str) -> bytes: + """Insert the token ```` right after ```` in *html*.""" + meta = f''.encode("ascii") + match = _HEAD_TAG.search(html) + if match is None: + return meta + html + return html[: match.end()] + meta + html[match.end() :] diff --git a/steam_backlog_enforcer/_web_daemon.py b/steam_backlog_enforcer/_web_daemon.py new file mode 100644 index 0000000..34bf4b1 --- /dev/null +++ b/steam_backlog_enforcer/_web_daemon.py @@ -0,0 +1,153 @@ +"""The root daemon as the web API sees it: status, and the two-phase reset. + +Every call goes through :func:`daemon_call`, which turns the client's +``DaemonUnreachableError`` / ``CtlError`` into contract errors. Calls block for +as long as the daemon takes (up to its own timeouts); each request has its +own thread (``ThreadingHTTPServer``), so a slow op stalls nobody else. + +The daemon answers the two-phase reset with ``pending_id`` and an extra +``lapse_after_seconds``; :func:`pending_action` maps that onto the +contract's ``PendingAction``. +""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer import _ctl_client +from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError +from steam_backlog_enforcer._web_errors import ( + ApiError, + daemon_unreachable, + from_ctl_error, +) +from steam_backlog_enforcer._web_io import Reply, no_content, ok +from steam_backlog_enforcer.jobs._daemon_jobs import ( + EXIT_FAILED, + EXIT_OK, + DaemonOutcome, + record_daemon_job, +) +from steam_backlog_enforcer.jobs._events import now_iso +from steam_backlog_enforcer.jobs._view import read_job + +if TYPE_CHECKING: + from collections.abc import Callable + + from steam_backlog_enforcer._ctl_types import PendingReset + from steam_backlog_enforcer._web_io import Request + +_JOURNAL_LINES: Final = 50 +_RESET: Final = "gaming-reset" + + +def daemon_call[**P, R](fn: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R: + """Run one control-socket call, mapping its failures to :class:`ApiError`. + + Raises: + ApiError: ``daemon_unreachable`` (503) or the daemon's own code. + """ + try: + return fn(*args, **kwargs) + except DaemonUnreachableError as exc: + raise daemon_unreachable(str(exc)) from None + except CtlError as exc: + raise from_ctl_error(exc) from None + + +def daemon_view(_request: Request) -> Reply: + """``GET /api/daemon`` — ``state: 'unreachable'`` when nobody answers.""" + try: + info = _ctl_client.status() + except DaemonUnreachableError: + return ok( + { + "state": "unreachable", + "started_at": None, + "pid": None, + "journal_tail": [], + "restart_available_at": None, + } + ) + except CtlError as exc: + raise from_ctl_error(exc) from None + try: + journal = _ctl_client.journal_tail(_JOURNAL_LINES) + except DaemonUnreachableError, CtlError: + journal = [] # The status still stands without its log. + return ok( + { + "state": info.state, + "started_at": info.started_at, + "pid": info.pid, + "journal_tail": journal, + "restart_available_at": info.restart_available_at, + } + ) + + +def pending_action(pending: PendingReset) -> dict[str, object]: + """The contract ``PendingAction`` for an armed gaming reset.""" + return { + "id": pending.pending_id, + "command": _RESET, + "phrase": pending.phrase, + "armed_at": pending.armed_at, + "ready_at": pending.ready_at, + "heartbeat_interval_seconds": pending.heartbeat_interval_seconds, + } + + +def confirm_phrase_of(request: Request) -> str: + """The typed phrase from a body (``confirm_phrase``; ``confirmPhrase`` too).""" + raw = request.body.get("confirm_phrase", request.body.get("confirmPhrase")) + if raw is None: + return "" + if not isinstance(raw, str): + msg = "confirm_phrase must be a string." + raise ApiError(msg, code="invalid_params") + return raw + + +def heartbeat_view(request: Request) -> Reply: + """``POST /api/pending/{id}/heartbeat`` — keep the countdown alive.""" + pending = daemon_call(_ctl_client.gaming_reset_heartbeat, request.args[0]) + return ok(pending_action(pending)) + + +def cancel_pending_view(request: Request) -> Reply: + """``DELETE /api/pending/{id}`` — abandon the armed reset.""" + daemon_call(_ctl_client.gaming_reset_cancel, request.args[0]) + return no_content() + + +def commit_view(request: Request) -> Reply: + """``POST /api/pending/{id}/commit`` — run the reset, record it as a job.""" + created_at = now_iso() + try: + result = daemon_call( + _ctl_client.gaming_reset_commit, + request.args[0], + confirm_phrase_of(request), + ) + except ApiError as exc: + if exc.code == "op_failed": + failed = DaemonOutcome(ok=False, summary=exc.message, exit_code=EXIT_FAILED) + record_daemon_job(_RESET, {}, created_at=created_at, outcome=failed) + raise + minutes = result.seconds_before / 60 + summary = f"Today's gaming budget reset ({minutes:.0f} min billed before)." + done = DaemonOutcome( + ok=True, + summary=summary, + exit_code=EXIT_OK, + data={ + "day_key": result.day_key, + "seconds_before": result.seconds_before, + "was_blocked": result.was_blocked, + "released": result.released, + }, + ) + job_id = record_daemon_job(_RESET, {}, created_at=created_at, outcome=done) + return Reply(HTTPStatus.CREATED, read_job(job_id)) diff --git a/steam_backlog_enforcer/_web_errors.py b/steam_backlog_enforcer/_web_errors.py new file mode 100644 index 0000000..e98f16e --- /dev/null +++ b/steam_backlog_enforcer/_web_errors.py @@ -0,0 +1,105 @@ +"""The web API's error type and how every lower-level error maps onto it. + +Every refusal leaves the server as ``{"error": "", "message": "…"}`` +with the status below (``DOCS-web-control-api.md``, "Endpoints"). The job +store raises :class:`~steam_backlog_enforcer.jobs._errors.JobError` and the +daemon client raises :class:`~steam_backlog_enforcer._ctl_protocol.CtlError` +/ ``DaemonUnreachableError``; the converters here are the only place their codes +become HTTP statuses, so one code always means one status. +""" + +from __future__ import annotations + +from http import HTTPStatus +import math +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from steam_backlog_enforcer._ctl_protocol import CtlError + from steam_backlog_enforcer.jobs._errors import JobError + +STATUS_BY_CODE: Final[dict[str, HTTPStatus]] = { + "bad_token": HTTPStatus.FORBIDDEN, + "bad_origin": HTTPStatus.FORBIDDEN, + "bad_host": HTTPStatus.FORBIDDEN, + "unknown_command": HTTPStatus.BAD_REQUEST, + "invalid_params": HTTPStatus.BAD_REQUEST, + "wrong_phrase": HTTPStatus.BAD_REQUEST, + "not_found": HTTPStatus.NOT_FOUND, + "locked": HTTPStatus.CONFLICT, + "busy": HTTPStatus.CONFLICT, + "countdown_running": HTTPStatus.CONFLICT, + "not_cancellable": HTTPStatus.CONFLICT, + "pending_lapsed": HTTPStatus.GONE, + "rate_limited": HTTPStatus.TOO_MANY_REQUESTS, + "daemon_unreachable": HTTPStatus.SERVICE_UNAVAILABLE, + "server_stale": HTTPStatus.SERVICE_UNAVAILABLE, + "op_failed": HTTPStatus.BAD_GATEWAY, + "unsupported": HTTPStatus.NOT_IMPLEMENTED, +} + + +class ApiError(Exception): + """A request the server answers with a contract error body. + + Attributes: + code: The contract's ``ApiErrorCode``. + message: Human text for the body. + status: HTTP status (defaults to the code's entry in the table). + retry_after: Seconds the client should wait (``Retry-After``), if any. + """ + + def __init__( + self, + message: str, + *, + code: str, + status: HTTPStatus | None = None, + retry_after: float | None = None, + ) -> None: + """Store the code, message, status and optional retry delay.""" + super().__init__(f"{code}: {message}") + self.code = code + self.message = message + self.status = status or STATUS_BY_CODE.get(code, HTTPStatus.BAD_GATEWAY) + self.retry_after = retry_after + + def body(self) -> dict[str, object]: + """The JSON object sent to the client.""" + out: dict[str, object] = {"error": self.code, "message": self.message} + if self.retry_after is not None: + out["retry_after_seconds"] = self.retry_after + return out + + def headers(self) -> dict[str, str]: + """Extra response headers (``Retry-After`` when a delay is known).""" + if self.retry_after is None: + return {} + return {"Retry-After": str(max(1, math.ceil(self.retry_after)))} + + +def not_found(message: str) -> ApiError: + """A ``not_found`` 404 for an unknown job, backup or endpoint.""" + return ApiError(message, code="not_found") + + +def from_job_error(exc: JobError) -> ApiError: + """Convert a job-store refusal (an unknown job id is ``not_found``).""" + return ApiError(exc.message, code=exc.code) + + +def from_ctl_error(exc: CtlError) -> ApiError: + """Convert a daemon refusal; codes the contract lacks become ``op_failed``.""" + code = exc.code if exc.code in STATUS_BY_CODE else "op_failed" + retry = exc.data.get("retry_after_seconds") if exc.data else None + return ApiError( + exc.message or exc.code, + code=code, + retry_after=float(retry) if isinstance(retry, int | float) else None, + ) + + +def daemon_unreachable(detail: str) -> ApiError: + """The 503 for a daemon that did not answer on its socket.""" + msg = f"The enforcer daemon is not answering on its control socket ({detail})." + return ApiError(msg, code="daemon_unreachable") diff --git a/steam_backlog_enforcer/_web_io.py b/steam_backlog_enforcer/_web_io.py new file mode 100644 index 0000000..cb807bd --- /dev/null +++ b/steam_backlog_enforcer/_web_io.py @@ -0,0 +1,163 @@ +"""Request and reply plumbing shared by every API route. + +Routes are plain functions ``(Request) -> Reply``; this module turns the raw +``BaseHTTPRequestHandler`` into a :class:`Request` (body size cap, JSON +content-type check) and writes a :class:`Reply` or an +:class:`~steam_backlog_enforcer._web_errors.ApiError` back. No CORS headers +are ever sent: the API is same-origin only. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from http import HTTPStatus +import json +from typing import TYPE_CHECKING, Any, Final + +from steam_backlog_enforcer._web_errors import ApiError + +if TYPE_CHECKING: + from email.message import Message + from http.server import BaseHTTPRequestHandler + + +# Requests are a handful of short fields; the cap stops a client from +# making the server buffer an arbitrary upload. +MAX_BODY_BYTES: Final = 64 * 1024 + + +@dataclass(frozen=True) +class Request: + """One parsed API request. + + Attributes: + method: ``GET``, ``POST`` or ``DELETE``. + path: URL path without the query. + query: Parsed query string (first value per key). + args: Path segments captured by the route pattern. + headers: The raw request headers. + body: Parsed JSON object body (empty when there was none). + """ + + method: str + path: str + query: dict[str, str] + args: tuple[str, ...] + headers: Message + body: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class Reply: + """A route's answer: a status and an optional JSON payload. + + A route that answers with something other than JSON (cover art) sets + *raw* and *ctype* instead; *headers* are sent as they are. + """ + + status: HTTPStatus + payload: object = None + raw: bytes | None = None + ctype: str = "" + headers: dict[str, str] = field(default_factory=dict) + + +def ok(payload: object) -> Reply: + """A ``200`` with *payload* as JSON.""" + return Reply(HTTPStatus.OK, payload) + + +def no_content() -> Reply: + """A ``204`` with no body.""" + return Reply(HTTPStatus.NO_CONTENT) + + +def read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]: + """Read and parse the request body; an absent body is ``{}``. + + Raises: + ApiError: ``invalid_params`` (413/415/400) for an oversized, + non-JSON or malformed body. + """ + if handler.headers.get("Transfer-Encoding"): + msg = "Chunked request bodies are not accepted; send Content-Length." + raise ApiError(msg, code="invalid_params", status=HTTPStatus.LENGTH_REQUIRED) + try: + length = int(handler.headers.get("Content-Length") or 0) + except ValueError: + msg = "Content-Length is not a number." + raise ApiError(msg, code="invalid_params") from None + if length <= 0: + return {} + if length > MAX_BODY_BYTES: + msg = f"Request body over {MAX_BODY_BYTES} bytes." + raise ApiError( + msg, code="invalid_params", status=HTTPStatus.REQUEST_ENTITY_TOO_LARGE + ) + ctype = (handler.headers.get("Content-Type") or "").split(";", 1)[0].strip() + if ctype.lower() != "application/json": + msg = "Request bodies must be application/json." + raise ApiError( + msg, code="invalid_params", status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE + ) + try: + body = json.loads(handler.rfile.read(length)) + except ValueError: + msg = "Request body is not valid JSON." + raise ApiError(msg, code="invalid_params") from None + if not isinstance(body, dict): + msg = "Request body must be a JSON object." + raise ApiError(msg, code="invalid_params") + return body + + +def send_bytes( + handler: BaseHTTPRequestHandler, + status: HTTPStatus, + body: bytes, + ctype: str, + headers: dict[str, str] | None = None, +) -> None: + """Write a complete response with a fixed-length body.""" + handler.send_response(status) + handler.send_header("Content-Type", ctype) + handler.send_header("Content-Length", str(len(body))) + handler.send_header("X-Content-Type-Options", "nosniff") + for name, value in (headers or {}).items(): + handler.send_header(name, value) + handler.end_headers() + if body: + handler.wfile.write(body) + + +def send_reply(handler: BaseHTTPRequestHandler, reply: Reply) -> None: + """Write a route's :class:`Reply` (``None`` payload means no body).""" + if reply.raw is not None: + send_bytes(handler, reply.status, reply.raw, reply.ctype, reply.headers) + return + if reply.payload is None: + # 202/204 without a body: no Content-Type, nothing to sniff. + handler.send_response(reply.status) + handler.send_header("Content-Length", "0") + handler.end_headers() + return + body = json.dumps(reply.payload).encode("utf-8") + send_bytes( + handler, + reply.status, + body, + "application/json; charset=utf-8", + {"Cache-Control": "no-store"}, + ) + + +def send_error(handler: BaseHTTPRequestHandler, error: ApiError) -> None: + """Write an :class:`ApiError` as the contract's error body.""" + body = json.dumps(error.body()).encode("utf-8") + send_bytes( + handler, + error.status, + body, + "application/json; charset=utf-8", + {"Cache-Control": "no-store", **error.headers()}, + ) diff --git a/steam_backlog_enforcer/_web_jobs.py b/steam_backlog_enforcer/_web_jobs.py new file mode 100644 index 0000000..208a65c --- /dev/null +++ b/steam_backlog_enforcer/_web_jobs.py @@ -0,0 +1,151 @@ +"""Job routes: list, create, read, answer, cancel, and backup restore. + +``POST /api/jobs`` decides where a command runs *before* the job store sees +it: privileged commands go to the root daemon +(:func:`._web_privileged.run_privileged`), everything else becomes a job +subprocess. Deciding first matters because the store guards jobs with the +one-mutating-job lock, and an emergency ``gaming-unblock`` must not be +refused as ``busy`` because a scan is running. +""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import TYPE_CHECKING, Any + +from steam_backlog_enforcer._backups import list_backups +from steam_backlog_enforcer._command_params import InvalidParamsError, validate_params +from steam_backlog_enforcer._friction import expected_phrase, phrase_matches +from steam_backlog_enforcer._web_daemon import confirm_phrase_of +from steam_backlog_enforcer._web_errors import ApiError, from_job_error, not_found +from steam_backlog_enforcer._web_io import Reply, no_content, ok +from steam_backlog_enforcer._web_privileged import run_privileged +from steam_backlog_enforcer.jobs._control import cancel, write_answer +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._specs import JOB_FLAGS, is_privileged +from steam_backlog_enforcer.jobs._store import create_job +from steam_backlog_enforcer.jobs._view import list_jobs, read_job + +if TYPE_CHECKING: + from steam_backlog_enforcer._web_io import Request + +# The UI's enforce modes onto the job registry's ``demo`` flag; ``restart`` +# (demo 0) is the daemon's. +_ENFORCE_MODES = {"demo": 1, "restart": 0} + + +def _invalid(message: str) -> ApiError: + """An ``invalid_params`` 400.""" + return ApiError(message, code="invalid_params") + + +def _enforce_params(params: dict[str, Any]) -> dict[str, Any]: + """Translate ``{"mode": "demo"|"restart"}`` into the registry's ``demo``. + + Raises: + ApiError: ``invalid_params`` for a missing or unknown mode. + """ + if "mode" not in params and "demo" in params: + return params + mode = params.get("mode") + if set(params) - {"mode"} or mode not in _ENFORCE_MODES: + msg = "enforce needs params.mode: 'restart' or 'demo'." + raise _invalid(msg) + return {"demo": _ENFORCE_MODES[str(mode)]} + + +def _job_request(request: Request) -> tuple[str, dict[str, Any], str | None]: + """Unpack a ``JobRequest`` body. + + Raises: + ApiError: ``invalid_params`` for a malformed body. + """ + command, params = request.body.get("command"), request.body.get("params", {}) + phrase = request.body.get("confirm_phrase") + if not isinstance(command, str) or not isinstance(params, dict): + msg = "Body must be {command: string, params: object}." + raise _invalid(msg) + if phrase is not None and not isinstance(phrase, str): + msg = "confirm_phrase must be a string." + raise _invalid(msg) + if command == "enforce": + params = _enforce_params(params) + return command, params, phrase + + +def jobs_view(_request: Request) -> Reply: + """``GET /api/jobs`` — newest first, at most 50.""" + return ok(list_jobs()) + + +def job_view(request: Request) -> Reply: + """``GET /api/jobs/{id}``.""" + try: + return ok(read_job(request.args[0])) + except JobError as exc: + raise from_job_error(exc) from None + + +def create_job_view(request: Request) -> Reply: + """``POST /api/jobs`` — ``201`` Job, or ``202`` PendingAction (reset).""" + command, params, phrase = _job_request(request) + if command not in JOB_FLAGS: + msg = f"{command!r} does not run as a job." + raise ApiError(msg, code="unknown_command") + try: + clean = validate_params(command, params) + except InvalidParamsError as exc: + raise _invalid(str(exc)) from None + if is_privileged(command, clean): + return run_privileged(command, clean, phrase) + try: + job_id = create_job(command, clean, confirm_phrase=phrase) + except JobError as exc: + raise from_job_error(exc) from None + return Reply(HTTPStatus.CREATED, read_job(job_id)) + + +def answer_view(request: Request) -> Reply: + """``POST /api/jobs/{id}/answer`` — deliver a ``PromptAnswer``.""" + prompt_id, value = request.body.get("prompt_id"), request.body.get("value") + if not isinstance(prompt_id, str) or not isinstance(value, str): + msg = "Body must be {prompt_id: string, value: string}." + raise _invalid(msg) + try: + write_answer(request.args[0], prompt_id, value) + except JobError as exc: + raise from_job_error(exc) from None + return no_content() + + +def cancel_view(request: Request) -> Reply: + """``POST /api/jobs/{id}/cancel`` — only for cancellable commands.""" + try: + cancel(request.args[0]) + except JobError as exc: + raise from_job_error(exc) from None + return no_content() + + +def restore_backup_view(request: Request) -> Reply: + """``POST /api/backups/{id}/restore`` — phrase checked here, then a job. + + The job re-checks the same phrase (it arrives as the job's preset + answer), so neither layer trusts the other. + """ + backup_id = request.args[0] + if backup_id not in {backup.id for backup in list_backups()}: + msg = f"No such backup: {backup_id!r}" + raise not_found(msg) + typed = confirm_phrase_of(request) + expected = expected_phrase("restore-backup", backup_id=backup_id) or "" + if not phrase_matches(expected, typed): + msg = f'Type "{expected}" to restore this backup.' + raise ApiError(msg, code="wrong_phrase") + try: + job_id = create_job( + "restore-backup", {"backup_id": backup_id}, confirm_phrase=typed + ) + except JobError as exc: + raise from_job_error(exc) from None + return Reply(HTTPStatus.CREATED, read_job(job_id)) diff --git a/steam_backlog_enforcer/_web_library.py b/steam_backlog_enforcer/_web_library.py new file mode 100644 index 0000000..f0a8b2c --- /dev/null +++ b/steam_backlog_enforcer/_web_library.py @@ -0,0 +1,169 @@ +"""``GET /api/library``: every owned game, for the web library browser. + +Joins the owned-games records (names, playtime, last played: see +:mod:`._owned_apps_cache`), the achievement snapshot, the HLTB cache, the +installed manifests and the state. Eligibility for "pick my own game" comes +from :func:`steam_backlog_enforcer._own_pick.ineligible_reason`, the same +rule the job enforces on the answer. + +Reads local files only, except once: when no refresh has stored owned-game +records yet, it asks Steam for them (the snapshot alone misses every game +without achievements). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +from steam_backlog_enforcer import config as _config +from steam_backlog_enforcer._actions import allowed_app_ids +from steam_backlog_enforcer._hltb_types import _read_raw_cache +from steam_backlog_enforcer._own_pick import ineligible_reason +from steam_backlog_enforcer._owned_apps_cache import ( + load_owned_records, + owned_cache_stamp, + refresh_owned_records, +) +from steam_backlog_enforcer._snapshot import load_snapshot +from steam_backlog_enforcer._web_io import ok +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.game_install import get_installed_games +from steam_backlog_enforcer.steam_api import GameInfo + +if TYPE_CHECKING: + from steam_backlog_enforcer._web_io import Reply, Request + + +@dataclass(frozen=True) +class _SnapshotMemo: + """The snapshot as achievement-free ``GameInfo``s, keyed by file stamp.""" + + stamp: tuple[int, int] + games: dict[int, GameInfo] + + +@dataclass +class _Memos: + """The process-wide memos, each keyed by the stamp of its source file.""" + + snapshot: _SnapshotMemo | None = None + owned_ids: tuple[tuple[int, int], set[int]] | None = None + + +_MEMOS = _Memos() + + +def _snapshot_games() -> dict[int, GameInfo]: + """Snapshot rows by app id; re-parsed only when the file changes. + + The snapshot is ~11 MB of per-achievement detail the library never + shows, so one parse per file version, not one per request. + """ + try: + st = _config.SNAPSHOT_FILE.stat() + except OSError: + return {} + stamp = (st.st_mtime_ns, st.st_size) + memo = _MEMOS.snapshot + if memo is None or memo.stamp != stamp: + games = { + int(row["app_id"]): GameInfo( + app_id=int(row["app_id"]), + name=str(row.get("name") or ""), + total_achievements=int(row.get("total_achievements") or 0), + unlocked_achievements=int(row.get("unlocked_achievements") or 0), + playtime_minutes=int(row.get("playtime_minutes") or 0), + completionist_hours=float(row.get("completionist_hours") or -1), + ) + for row in load_snapshot() or [] + if "app_id" in row + } + memo = _MEMOS.snapshot = _SnapshotMemo(stamp, games) + return memo.games + + +def _owned_records(config: Config) -> dict[int, dict[str, Any]]: + """Owned-game records by app id; fetched once when none are cached.""" + records = load_owned_records(config.steam_id) + if records is None: + records = refresh_owned_records(config) or [] + return {int(r["app_id"]): r for r in records if "app_id" in r} + + +def _hltb_hours( + app_id: int, game: GameInfo | None, hltb: dict[int, Any] +) -> float | None: + """Completionist hours: the HLTB cache, else the snapshot, else unknown.""" + hours = float(hltb.get(app_id, {}).get("hours") or -1) + if hours <= 0 and game is not None: + hours = game.completionist_hours + return round(hours, 1) if hours > 0 else None + + +def owned_app_ids() -> set[int]: + """Every app id the library can list (the art route serves only these). + + Never asks Steam: an image request must not turn into an API call. Kept + per owned-cache file version, since a grid load asks once per cover. + """ + stamp = owned_cache_stamp() + snapshot = _snapshot_games() + memo = _MEMOS.owned_ids + if memo is None or memo[0] != stamp: + records = load_owned_records(Config.load().steam_id) or [] + ids = {int(r["app_id"]) for r in records if "app_id" in r} + memo = _MEMOS.owned_ids = (stamp, ids) + return set(snapshot) | memo[1] + + +def _reason( + app_id: int, + game: GameInfo | None, + record: dict[str, Any], + state: State, + skipped: set[int], +) -> str | None: + """:func:`ineligible_reason`, saying *why* a game has no scan data.""" + if game is None and record.get("has_stats") is False: + return "No achievements" + if game is None and record.get("has_stats"): + return "Not scanned yet" + return ineligible_reason(app_id, game, state, skipped) + + +def library_rows(state: State, config: Config) -> list[dict[str, object]]: + """One contract ``LibraryGame`` per owned game, in no particular order.""" + snapshot = _snapshot_games() + records = _owned_records(config) + hltb = _read_raw_cache() + installed = {app_id for app_id, _ in get_installed_games()} + allowed = allowed_app_ids(state) + skipped = state.active_skipped_ids() + rows: list[dict[str, object]] = [] + for app_id in records.keys() | snapshot.keys(): + game = snapshot.get(app_id) + record = records.get(app_id, {}) + rows.append( + { + "app_id": app_id, + "name": record.get("name") or (game.name if game else str(app_id)), + "achievements_total": game.total_achievements if game else 0, + "achievements_unlocked": game.unlocked_achievements if game else 0, + "hltb_hours": _hltb_hours(app_id, game, hltb), + "playtime_minutes": int( + record.get("playtime_minutes") + or (game.playtime_minutes if game else 0) + ), + "last_played": int(record.get("last_played") or 0) or None, + "installed": app_id in installed, + "assigned": app_id in allowed, + "ineligible_reason": _reason(app_id, game, record, state, skipped), + } + ) + return rows + + +def library_view(_request: Request) -> Reply: + """``GET /api/library`` — every owned game as ``LibraryPayload``.""" + return ok({"games": library_rows(State.load(), Config.load())}) diff --git a/steam_backlog_enforcer/_web_privileged.py b/steam_backlog_enforcer/_web_privileged.py new file mode 100644 index 0000000..aa3ef4e --- /dev/null +++ b/steam_backlog_enforcer/_web_privileged.py @@ -0,0 +1,141 @@ +"""Privileged commands: ``POST /api/jobs`` requests the root daemon carries out. + +The web server never gets root, so ``unblock``, ``buy-dlc``, ``block-gaming``, +``gaming-unblock``, ``gaming-reset`` and the real ``enforce`` restart become +control-socket ops. The typed phrase is passed through untouched: the daemon +is the trust boundary and checks it, along with the locks and caps. + +A completed op is recorded as a finished job (:mod:`.jobs._daemon_jobs`) so +it shows in the same history as everything else; so is an op the daemon +ran and failed (``op_failed``). Refusals — wrong phrase, locked, rate limited, +daemon unreachable — are answered as errors and leave no job behind. +``gaming-reset`` answers ``202`` with a ``PendingAction`` instead: its job is +written when it is committed (:func:`._web_daemon.commit_view`). +""" + +from __future__ import annotations + +from datetime import datetime +from http import HTTPStatus +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer import _ctl_client +from steam_backlog_enforcer._store_window import DEFAULT_WINDOW_MINUTES +from steam_backlog_enforcer._web_daemon import daemon_call, pending_action +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_io import Reply +from steam_backlog_enforcer.jobs._daemon_jobs import ( + EXIT_FAILED, + EXIT_OK, + DaemonOutcome, + record_daemon_job, +) +from steam_backlog_enforcer.jobs._events import now_iso +from steam_backlog_enforcer.jobs._view import read_job + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from steam_backlog_enforcer._command_params import ParamValue + +type Outcome = tuple[str, dict[str, object]] +type Op = Callable[[Mapping[str, ParamValue], str], Outcome] + +# buy-dlc has no knobs: always the default-length store window. +BUY_DLC_MINUTES: Final = DEFAULT_WINDOW_MINUTES + + +def _local_hhmm(iso: str | None) -> str: + """``HH:MM`` local time of an ISO timestamp (the raw text if unparsable).""" + if not iso: + return "?" + try: + return datetime.fromisoformat(iso).astimezone().strftime("%H:%M") + except ValueError: + return iso + + +def _store_window(minutes: int, phrase: str) -> Outcome: + """Open the store for *minutes*.""" + window = daemon_call(_ctl_client.store_unblock, minutes, phrase) + summary = ( + f"Store unblocked for {window.minutes} min, until {_local_hhmm(window.until)}." + ) + return summary, {"minutes": window.minutes, "until": window.until} + + +def _unblock(params: Mapping[str, ParamValue], phrase: str) -> Outcome: + """``unblock`` — a 1-30 minute store window.""" + return _store_window(int(params["minutes"]), phrase) + + +def _buy_dlc(_params: Mapping[str, ParamValue], phrase: str) -> Outcome: + """``buy-dlc`` — the fixed default-length store window.""" + return _store_window(BUY_DLC_MINUTES, phrase) + + +def _block_gaming(params: Mapping[str, ParamValue], phrase: str) -> Outcome: + """``block-gaming`` — the irreversible total block.""" + result = daemon_call(_ctl_client.block_gaming, int(params["days"]), phrase) + summary = f"Total gaming block active for {result.days} day(s)." + return summary, {"days": result.days, "until": result.until} + + +def _gaming_unblock(_params: Mapping[str, ParamValue], phrase: str) -> Outcome: + """``gaming-unblock`` — force-release the playtime mounts.""" + released = daemon_call(_ctl_client.gaming_unblock, phrase) + return f"Released {len(released)} playtime mount(s).", {"released": released} + + +def _restart(_params: Mapping[str, ParamValue], _phrase: str) -> Outcome: + """``enforce`` (restart) — flush, exit, come back under systemd.""" + available = daemon_call(_ctl_client.restart) + summary = "The enforcer daemon is restarting (state flushed first)." + return summary, {"restart_available_at": available} + + +OPS: Final[dict[str, Op]] = { + "unblock": _unblock, + "buy-dlc": _buy_dlc, + "block-gaming": _block_gaming, + "gaming-unblock": _gaming_unblock, + "enforce": _restart, +} + + +def run_privileged( + command: str, params: Mapping[str, ParamValue], phrase: str | None +) -> Reply: + """Carry out a privileged command through the daemon. + + Args: + command: Canonical command name. + params: Params already validated against the command's spec. + phrase: ``JobRequest.confirm_phrase`` (checked by the daemon). + + Returns: + ``201`` with the recorded ``Job``, or ``202`` with a + ``PendingAction`` for ``gaming-reset``. + + Raises: + ApiError: The daemon's refusal, mapped to its HTTP status. + """ + typed = phrase or "" + if command == "gaming-reset": + pending = daemon_call(_ctl_client.gaming_reset_arm, typed) + return Reply(HTTPStatus.ACCEPTED, pending_action(pending)) + op = OPS.get(command) + if op is None: + msg = f"{command!r} is not a daemon command." + raise ApiError(msg, code="unknown_command") + created_at = now_iso() + try: + summary, data = op(params, typed) + except ApiError as exc: + if exc.code == "op_failed": + failed = DaemonOutcome(ok=False, summary=exc.message, exit_code=EXIT_FAILED) + record_daemon_job(command, params, created_at=created_at, outcome=failed) + raise + done = DaemonOutcome(ok=True, summary=summary, exit_code=EXIT_OK, data=data) + job_id = record_daemon_job(command, params, created_at=created_at, outcome=done) + return Reply(HTTPStatus.CREATED, read_job(job_id)) diff --git a/steam_backlog_enforcer/_web_process.py b/steam_backlog_enforcer/_web_process.py new file mode 100644 index 0000000..b3b36dd --- /dev/null +++ b/steam_backlog_enforcer/_web_process.py @@ -0,0 +1,116 @@ +"""The server process itself: when it started, what code it runs, stand-down. + +``ServerHealth`` (``GET /api/server``) and ``POST /api/server/restart`` live +here, next to the retirement switch the stale-code check +(:mod:`steam_backlog_enforcer._serve_stale`) already used: a restart is the +same stand-down, asked for instead of detected. Exiting is enough because +``steam-backlog-enforcer-web.service`` has ``Restart=always``; jobs survive +it, being their own processes in their own systemd scopes. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from functools import cache +from http import HTTPStatus +import os +from pathlib import Path +import shutil +import subprocess +import threading +import time +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._serve_stale import newest_py_after, outdated_source +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_io import Reply, ok + +if TYPE_CHECKING: + from socketserver import BaseServer + + from steam_backlog_enforcer._web_io import Request + +# Epoch seconds this process began, captured at import. Anything in the +# package newer than this is code we did not load. +STARTED_AT: Final = time.time() +# Set once the server has begun standing down, so concurrent requests do +# not each spawn a shutdown thread (and SSE streams know to end). +RETIRING: Final = threading.Event() +# Long enough for the 202 to leave before the process goes. +_RESTART_DELAY_SECONDS: Final = 0.5 +_REPO_ROOT: Final = Path(__file__).resolve().parent.parent +_server: list[BaseServer] = [] + + +def register(server: BaseServer) -> None: + """Remember the running server so :func:`retire` can stop it.""" + _server[:] = [server] + + +def retire(delay: float = 0.0) -> None: + """Stop serving (once) so the supervisor starts current code. + + ``shutdown`` blocks until ``serve_forever`` returns and cannot run on a + request thread, so it gets its own; *delay* lets the reply that asked + for it reach the client first. + """ + if RETIRING.is_set() or not _server: + return + RETIRING.set() + timer = threading.Timer(delay, _server[0].shutdown) + timer.daemon = True + timer.start() + + +def _iso(epoch: float) -> str: + """Epoch seconds as ISO-8601 UTC with a ``Z`` suffix.""" + return datetime.fromtimestamp(epoch, UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +@cache +def version() -> str: + """The git short sha of the checkout, else the newest source mtime.""" + git = shutil.which("git") + if git is not None: + try: + out = subprocess.run( + [git, "-C", str(_REPO_ROOT), "rev-parse", "--short", "HEAD"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + except OSError, subprocess.SubprocessError: + pass + else: + return out.stdout.strip() + newest = newest_py_after(0.0) + return _iso(newest.stat().st_mtime) if newest else "unknown" + + +def health() -> dict[str, object]: + """The contract ``ServerHealth``.""" + return { + "stale": outdated_source(STARTED_AT) is not None, + "started_at": _iso(STARTED_AT), + "version": version(), + } + + +def health_view(_request: Request) -> Reply: + """``GET /api/server``.""" + return ok(health()) + + +def restart_view(_request: Request) -> Reply: + """``POST /api/server/restart`` — answer 202, then exit for systemd. + + Raises: + ApiError: ``unsupported`` when no supervisor would bring the server + back (started by hand, the restart would be a stop). + """ + if not os.environ.get("INVOCATION_ID"): + msg = "This server is not running under systemd; a restart would stop it." + raise ApiError(msg, code="unsupported") + retire(_RESTART_DELAY_SECONDS) + return Reply(HTTPStatus.ACCEPTED) diff --git a/steam_backlog_enforcer/_web_routes.py b/steam_backlog_enforcer/_web_routes.py new file mode 100644 index 0000000..db12911 --- /dev/null +++ b/steam_backlog_enforcer/_web_routes.py @@ -0,0 +1,93 @@ +"""The API's route table: method + path pattern → route function. + +One table, so the whole surface (``DOCS-web-control-api.md``, "Endpoints") +can be read in one screen. Path ids are restricted to the characters the +stores issue; the stores re-validate them anyway. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer import ( + _web_art, + _web_daemon, + _web_jobs, + _web_library, + _web_process, + _web_setup, + _web_views, +) + +if TYPE_CHECKING: + from collections.abc import Callable + + from steam_backlog_enforcer._web_io import Reply, Request + +type Route = Callable[[Request], Reply] + +_ID = r"([0-9A-Za-z_-]{1,64})" +# Matched before the table by the server: it streams instead of replying. +EVENTS_PATH: Final = re.compile(rf"^/api/jobs/{_ID}/events$") + + +@dataclass(frozen=True) +class _Entry: + """One route.""" + + method: str + pattern: re.Pattern[str] + route: Route + + +def _r(method: str, path: str, route: Route) -> _Entry: + """Build an entry; ``{id}`` in *path* captures one id segment.""" + return _Entry(method, re.compile("^" + path.replace("{id}", _ID) + "$"), route) + + +ROUTES: Final[tuple[_Entry, ...]] = ( + _r("GET", "/api/dataset", _web_views.dataset_view), + _r("GET", "/api/budget", _web_views.budget_view), + _r("GET", "/api/status", _web_views.status_view), + _r("GET", "/api/stats", _web_views.stats_view), + _r("GET", "/api/installed", _web_views.installed_view), + _r("GET", "/api/library", _web_library.library_view), + _r("GET", "/api/art/{id}", _web_art.art_view), + _r("GET", "/api/commands", _web_views.commands_view), + _r("GET", "/api/setup", _web_views.setup_view), + _r("POST", "/api/setup", _web_setup.save_setup), + _r("GET", "/api/daemon", _web_daemon.daemon_view), + _r("GET", "/api/server", _web_process.health_view), + _r("POST", "/api/server/restart", _web_process.restart_view), + _r("GET", "/api/backups", _web_views.backups_view), + _r("POST", "/api/backups/{id}/restore", _web_jobs.restore_backup_view), + _r("GET", "/api/jobs", _web_jobs.jobs_view), + _r("POST", "/api/jobs", _web_jobs.create_job_view), + _r("GET", "/api/jobs/{id}", _web_jobs.job_view), + _r("POST", "/api/jobs/{id}/answer", _web_jobs.answer_view), + _r("POST", "/api/jobs/{id}/cancel", _web_jobs.cancel_view), + _r("POST", "/api/pending/{id}/heartbeat", _web_daemon.heartbeat_view), + _r("POST", "/api/pending/{id}/commit", _web_daemon.commit_view), + _r("DELETE", "/api/pending/{id}", _web_daemon.cancel_pending_view), +) + + +def resolve(method: str, path: str) -> tuple[Route, tuple[str, ...]] | str | None: + """Find the route for *method* and *path*. + + Returns: + ``(route, captured ids)`` on a match; the string ``"method"`` when + the path exists under another method (405); ``None`` when no route + has this path (404). + """ + path_known = False + for entry in ROUTES: + match = entry.pattern.fullmatch(path) + if match is None: + continue + if entry.method == method: + return entry.route, match.groups() + path_known = True + return "method" if path_known else None diff --git a/steam_backlog_enforcer/_web_server.py b/steam_backlog_enforcer/_web_server.py index 1eea8ec..ff3bd98 100644 --- a/steam_backlog_enforcer/_web_server.py +++ b/steam_backlog_enforcer/_web_server.py @@ -1,10 +1,13 @@ -"""Minimal read-only localhost HTTP server for the interactive web UI. +"""Localhost HTTP server for the web UI: the control API and the React bundle. -Serves the projected dataset at ``GET /api/dataset`` and the built React -bundle (``web/dist``) as static files. Binds to localhost only and never -exposes secrets: the payload comes from :func:`build_web_dataset`, which reads -the data caches but never ``config.json``. +A thin router. Per request it, in order: checks ``Host`` (every route — the +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``). +Binds to loopback only; never serves secrets and never sends CORS headers. In development the Vite dev server proxies ``/api`` here; in production the ``serve`` command serves the built bundle and the API from one process. """ @@ -13,18 +16,23 @@ from __future__ import annotations from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -import json import logging import mimetypes from pathlib import Path -import threading -import time -from urllib.parse import urlsplit +from urllib.parse import parse_qs, urlsplit -from steam_backlog_enforcer._budget_view import build_budget_snapshot +from steam_backlog_enforcer import _web_auth, _web_process from steam_backlog_enforcer._serve_stale import outdated_source -from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload -from steam_backlog_enforcer.config import State +from steam_backlog_enforcer._web_errors import ApiError, not_found +from steam_backlog_enforcer._web_io import ( + Request, + read_json_body, + send_bytes, + send_error, + send_reply, +) +from steam_backlog_enforcer._web_routes import EVENTS_PATH, resolve +from steam_backlog_enforcer._web_sse import stream_job_events from steam_backlog_enforcer.game_install import _echo logger = logging.getLogger(__name__) @@ -34,132 +42,178 @@ WEB_DIST = (Path(__file__).resolve().parent.parent / "web" / "dist").resolve() DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8000 -_API_DATASET = "/api/dataset" -_API_BUDGET = "/api/budget" - -# Content types that are text but not under the ``text/`` prefix. -_EXTRA_TEXT_TYPES = frozenset( - {"application/javascript", "application/json", "image/svg+xml"} -) +_INDEX = "index.html" _NOT_BUILT_MSG = b"Frontend not built. Run: cd web && npm install && npm run build" - -# Epoch seconds this process began, captured at import. Anything in the -# package newer than this is code we did not load. -_STARTED_AT = time.time() - -# Set once a stale server has begun standing down, so concurrent requests -# do not each spawn a shutdown thread. -_RETIRING = threading.Event() - _STALE_MSG = ( - b"This server is running outdated code and has stopped answering rather " - b"than report numbers the enforcer is not applying. Restart it: " - b"./run.sh serve" + "This server is running outdated code and has stopped answering rather " + "than report numbers the enforcer is not applying. It restarts on " + "current code by itself under systemd; otherwise run ./run.sh serve" ) +class WebServer(ThreadingHTTPServer): + """The HTTP server plus its per-launch token.""" + + def __init__(self, address: tuple[str, int]) -> None: + """Bind to *address* and mint this launch's token.""" + super().__init__(address, _Handler) + self.token = _web_auth.new_token() + + @property + def port(self) -> int: + """The bound port (the real one, also when bound to port 0).""" + return int(self.server_address[1]) + + class _Handler(BaseHTTPRequestHandler): - """Serve the dataset JSON and the static frontend bundle (read-only).""" + """Route one request: auth, stale check, then API, SSE or static.""" + + server: WebServer def log_message(self, fmt: str, /, *args: object) -> None: """Route the default request log to ``logging`` at debug level.""" logger.debug("%s - %s", self.address_string(), fmt % args) def do_GET(self) -> None: - """Dispatch a GET to one of the APIs or to a static file.""" - stale = outdated_source(_STARTED_AT) - if stale is not None: - # Fail closed on every route, static included: a page served from a - # fresh bundle that then fetches numbers from stale code is the - # same lie with extra steps. - self._send(HTTPStatus.SERVICE_UNAVAILABLE, _STALE_MSG, "text/plain") - self._retire() - return + """Handle a GET.""" + self._handle("GET") + + def do_POST(self) -> None: + """Handle a POST.""" + self._handle("POST") + + def do_DELETE(self) -> None: + """Handle a DELETE.""" + self._handle("DELETE") + + def _handle(self, method: str) -> None: + """Run the checks, then dispatch; every refusal is a JSON error.""" split = urlsplit(self.path) - if split.path == _API_DATASET: - self._serve_dataset() - elif split.path == _API_BUDGET: - # ``?demo=1`` reads the demo run's state and log, which is how the - # 60-second demo can be watched hitting its cutoff in the browser - # without spending a real day's budget to see it. - self._serve_budget(demo="demo=1" in split.query) - else: - self._serve_static(split.path) + try: + host = _web_auth.check_host(self.headers, self.server.port) + if self._refused_as_stale(split.path): + return + if method != "GET": + _web_auth.check_origin(self.headers, host) + token = self.headers.get(_web_auth.AUTH_HEADER) + _web_auth.check_token(token, self.server.token) + self._dispatch(method, split.path, _query(split.query)) + except ApiError as exc: + send_error(self, exc) + except Exception: + logger.exception("%s %s failed", method, split.path) + msg = "The server failed to answer this request; see its log." + send_error( + self, + ApiError( + msg, code="op_failed", status=HTTPStatus.INTERNAL_SERVER_ERROR + ), + ) - def _retire(self) -> None: - """Answer this request, then stop the server so it can be replaced. + def _dispatch(self, method: str, path: str, query: dict[str, str]) -> None: + """Send the request to the SSE stream, an API route or the bundle.""" + events = EVENTS_PATH.fullmatch(path) if method == "GET" else None + if events is not None: + # EventSource cannot send headers: the token rides in the query. + _web_auth.check_token(query.get(_web_auth.AUTH_QUERY), self.server.token) + request = Request(method, path, query, events.groups(), self.headers) + stream_job_events(self, request) + return + if not path.startswith("/api/"): + if method != "GET": + raise _method_not_allowed() + self._serve_static(path) + return + found = resolve(method, path) + if found is None: + msg = f"No such endpoint: {path}" + raise not_found(msg) + if isinstance(found, str): + raise _method_not_allowed() + route, args = found + body = read_json_body(self) if method != "GET" else {} + send_reply(self, route(Request(method, path, query, args, self.headers, body))) - Refusing to serve is only half a fix: a process that 503s forever is - honest and useless. Exiting hands the problem to the supervisor, and - ``steam-backlog-enforcer-web.service`` has ``Restart=always``, so the - server comes back on current code with nobody having to remember to - restart it. Started outside systemd it simply exits, which is still - better than serving numbers the enforcer is not applying. + def _refused_as_stale(self, path: str) -> bool: + """Fail closed when our own source changed since this process started. - ``shutdown`` blocks until ``serve_forever`` returns and cannot be - called from the thread handling a request, so it goes to its own. + Every route refuses, static included: a page served from a fresh + bundle that then fetches numbers from stale code is the same lie + with extra steps. ``/api/server`` is the exception, because saying + "I am stale" is the one answer that is still true. Either way the + server then stands down so the supervisor restarts it on current + code (``Restart=always``). """ - if _RETIRING.is_set(): - return - _RETIRING.set() - threading.Thread(target=self.server.shutdown, daemon=True).start() - - def _serve_budget(self, *, demo: bool) -> None: - """Build and send the gaming-budget snapshot as JSON.""" - try: - body = json.dumps(build_budget_snapshot(demo=demo)).encode("utf-8") - except OSError, ValueError, KeyError: - logger.exception("Failed to build budget snapshot") - self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"budget error", "text/plain") - return - self._send(HTTPStatus.OK, body, "application/json") - - def _serve_dataset(self) -> None: - """Build and send the projected dataset as JSON.""" - try: - payload = dataset_to_payload(build_web_dataset(State.load())) - body = json.dumps(payload).encode("utf-8") - except OSError, ValueError, KeyError: - logger.exception("Failed to build web dataset") - self._send(HTTPStatus.INTERNAL_SERVER_ERROR, b"dataset error", "text/plain") - return - self._send(HTTPStatus.OK, body, "application/json") + if outdated_source(_web_process.STARTED_AT) is None: + return False + if path == "/api/server": + send_reply(self, _web_process.health_view(_no_request(path, self))) + elif path.startswith("/api/"): + send_error(self, ApiError(_STALE_MSG, code="server_stale")) + else: + body = _STALE_MSG.encode() + send_bytes( + self, HTTPStatus.SERVICE_UNAVAILABLE, body, "text/plain; charset=utf-8" + ) + _web_process.retire() + 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.html" + 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.html" + candidate = WEB_DIST / _INDEX if not candidate.is_file(): - self._send(HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain") + send_bytes(self, HTTPStatus.NOT_FOUND, _NOT_BUILT_MSG, "text/plain") return ctype, _ = mimetypes.guess_type(candidate.name) - self._send(HTTPStatus.OK, candidate.read_bytes(), ctype or "text/plain") - - def _send(self, status: HTTPStatus, body: bytes, ctype: str) -> None: - """Write a complete response with the given status, body, and type.""" - if ctype.startswith("text/") or ctype in _EXTRA_TEXT_TYPES: + ctype = ctype or "text/plain" + if ctype.startswith("text/") or ctype in _TEXT_TYPES: ctype = f"{ctype}; charset=utf-8" - self.send_response(status) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) + body = candidate.read_bytes() + headers: dict[str, str] = {} + if candidate.name == _INDEX and candidate.parent == WEB_DIST: + # 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) -def create_server( - host: str = DEFAULT_HOST, port: int = DEFAULT_PORT -) -> ThreadingHTTPServer: +# Content types that are text but not under the ``text/`` prefix. +_TEXT_TYPES = frozenset({"application/javascript", "application/json", "image/svg+xml"}) + + +def _query(raw: str) -> dict[str, str]: + """The query string, first value per key.""" + return {key: values[0] for key, values in parse_qs(raw).items()} + + +def _no_request(path: str, handler: _Handler) -> Request: + """A bodiless GET request for *path* (for routes that ignore it).""" + return Request("GET", path, {}, (), handler.headers) + + +def _method_not_allowed() -> ApiError: + """The 405 for a known path under the wrong method.""" + msg = "Method not allowed here." + return ApiError(msg, code="invalid_params", status=HTTPStatus.METHOD_NOT_ALLOWED) + + +def create_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> WebServer: """Create (but do not start) the threading HTTP server.""" - return ThreadingHTTPServer((host, port), _Handler) + server = WebServer((host, port)) + _web_process.register(server) + return server def serve(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: - """Run the web server until interrupted with Ctrl-C.""" + """Run the web server until interrupted (or until it stands down).""" server = create_server(host, port) + token_file = _web_auth.write_token_file(server.token, server.port) _echo(f"Steam Backlog Enforcer web UI: http://{host}:{port}") + _echo(f"Session token written to {token_file}.") _echo("Press Ctrl-C to stop.") try: server.serve_forever() diff --git a/steam_backlog_enforcer/_web_setup.py b/steam_backlog_enforcer/_web_setup.py new file mode 100644 index 0000000..c311180 --- /dev/null +++ b/steam_backlog_enforcer/_web_setup.py @@ -0,0 +1,91 @@ +"""``POST /api/setup``: store the Steam credentials, after Steam accepts them. + +The CLI's setup writes whatever is typed; the web form checks first, so a +typo cannot leave the enforcer with a key that fails on its next pass. One +``GetPlayerSummaries`` call proves both halves: Steam answers 401/403 for a +bad key, and an empty ``players`` list for a SteamID it does not know. +Nothing is written unless that call succeeds, and the key is never echoed +back — the reply is the same ``SetupStatus`` that ``GET`` serves. +""" + +from __future__ import annotations + +from http import HTTPStatus +import re +from typing import TYPE_CHECKING, Final + +import requests + +from steam_backlog_enforcer._command_gate import lock_reason +from steam_backlog_enforcer._config_setup import save_credentials +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_io import Reply, ok +from steam_backlog_enforcer._web_views import setup_status +from steam_backlog_enforcer.config import Config, State + +if TYPE_CHECKING: + from steam_backlog_enforcer._web_io import Request + +_SUMMARIES_URL: Final = "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/" +_TIMEOUT_SECONDS: Final = 15 +_API_KEY = re.compile(r"^[0-9A-Fa-f]{32}$") +_STEAM_ID = re.compile(r"^7656119\d{10}$") + + +def _field(request: Request, name: str, pattern: re.Pattern[str], what: str) -> str: + """One validated, stripped string field of the body. + + Raises: + ApiError: ``invalid_params`` when it is missing or malformed. + """ + raw = request.body.get(name) + value = raw.strip() if isinstance(raw, str) else "" + if not pattern.fullmatch(value): + msg = f"{name} must be {what}." + raise ApiError(msg, code="invalid_params") + return value + + +def verify_with_steam(api_key: str, steam_id: str) -> None: + """Ask Steam whether *api_key* works and *steam_id* exists. + + Raises: + ApiError: ``invalid_params`` for a rejected key or unknown id, + ``op_failed`` (502) when Steam cannot be asked at all. + """ + try: + resp = requests.get( + _SUMMARIES_URL, + params={"key": api_key, "steamids": steam_id}, + timeout=_TIMEOUT_SECONDS, + ) + except requests.RequestException as exc: + msg = ( + f"Could not reach the Steam Web API to check the key: {type(exc).__name__}." + ) + raise ApiError(msg, code="op_failed") from None + if resp.status_code in {HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN}: + msg = "Steam rejected this API key." + raise ApiError(msg, code="invalid_params") + if resp.status_code != HTTPStatus.OK: + msg = f"The Steam Web API answered HTTP {resp.status_code}; try again." + raise ApiError(msg, code="op_failed") + try: + players = resp.json()["response"]["players"] + except ValueError, KeyError, TypeError: + msg = "The Steam Web API sent an unexpected reply." + raise ApiError(msg, code="op_failed") from None + if not players: + msg = "Steam knows no account with this SteamID64." + raise ApiError(msg, code="invalid_params") + + +def save_setup(request: Request) -> Reply: + """``POST /api/setup`` — validate live, then save like the CLI does.""" + api_key = _field(request, "steam_api_key", _API_KEY, "a 32-character hex key") + steam_id = _field(request, "steam_id", _STEAM_ID, "a 17-digit SteamID64") + reason = lock_reason("setup", Config.load(), State.load()) + if reason is not None: + raise ApiError(reason, code="locked") + verify_with_steam(api_key, steam_id) + return ok(setup_status(save_credentials(api_key, steam_id))) diff --git a/steam_backlog_enforcer/_web_sse.py b/steam_backlog_enforcer/_web_sse.py new file mode 100644 index 0000000..1d5477b --- /dev/null +++ b/steam_backlog_enforcer/_web_sse.py @@ -0,0 +1,114 @@ +"""``GET /api/jobs/{id}/events``: a job's ``events.jsonl`` as Server-Sent Events. + +Each event is one unnamed frame — ``id: `` and ``data: `` +— so ``EventSource.onmessage`` sees every type and a reconnect resumes with +``Last-Event-ID``. That header wins over ``?after=``: ``EventSource`` keeps +the URL it was opened with, so on a reconnect ``after`` is stale. + +The stream ends once the job is over: right after the final ``state`` event +that follows ``result`` (the runner writes them back to back), or a short +grace after ``result`` if that state never comes. It also ends when the +server stands down; the browser reconnects to its replacement and resumes. +The server only reads files here, so a job is unaffected by any of this. +""" + +from __future__ import annotations + +from http import HTTPStatus +import json +import time +from typing import TYPE_CHECKING, Any, Final + +from steam_backlog_enforcer._web_errors import ApiError, from_job_error +from steam_backlog_enforcer._web_process import RETIRING +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._events import TERMINAL_STATES +from steam_backlog_enforcer.jobs._view import read_job, read_job_events + +if TYPE_CHECKING: + from http.server import BaseHTTPRequestHandler + + from steam_backlog_enforcer._web_io import Request + +_POLL_SECONDS: Final = 0.25 +_KEEPALIVE_SECONDS: Final = 15.0 +_RESULT_GRACE_SECONDS: Final = 2.0 +_RETRY_MS: Final = 2000 + + +def resume_after(request: Request) -> int: + """The last seq the client has: ``Last-Event-ID``, else ``?after=``. + + Raises: + ApiError: ``invalid_params`` for a non-numeric value. + """ + raw = request.headers.get("Last-Event-ID") or request.query.get("after") or "0" + try: + return max(0, int(raw)) + except ValueError: + msg = f"after must be an event seq, got {raw!r}" + raise ApiError(msg, code="invalid_params") from None + + +def _frame(event: dict[str, Any]) -> bytes: + """One SSE frame for *event*.""" + return f"id: {event['seq']}\ndata: {json.dumps(event)}\n\n".encode() + + +def _is_terminal_state(event: dict[str, Any]) -> bool: + """Whether *event* is the job's final ``state`` transition.""" + return event.get("type") == "state" and event.get("state") in TERMINAL_STATES + + +def _pump(handler: BaseHTTPRequestHandler, job_id: str, after: int) -> None: + """Write events as they appear until the job is over or we retire.""" + last_write = time.monotonic() + result_at: float | None = None + while not RETIRING.is_set(): + events = read_job_events(job_id, after) + for event in events: + handler.wfile.write(_frame(event)) + after = event["seq"] + if event.get("type") == "result": + result_at = time.monotonic() + if _is_terminal_state(event): + return + now = time.monotonic() + if events: + handler.wfile.flush() + last_write = now + elif result_at is not None and now - result_at > _RESULT_GRACE_SECONDS: + return + elif result_at is None and read_job(job_id)["state"] in TERMINAL_STATES: + return # Resumed past the end of a finished job: nothing more. + elif now - last_write >= _KEEPALIVE_SECONDS: + handler.wfile.write(b": keep-alive\n\n") + handler.wfile.flush() + last_write = now + time.sleep(_POLL_SECONDS) + + +def stream_job_events(handler: BaseHTTPRequestHandler, request: Request) -> None: + """Serve the SSE stream for ``request.args[0]`` (auth already checked). + + Raises: + ApiError: Before any byte is sent, for an unknown job or bad ``after``. + """ + job_id = request.args[0] + after = resume_after(request) + try: + read_job(job_id) + except JobError as exc: + raise from_job_error(exc) from None + handler.close_connection = True + handler.send_response(HTTPStatus.OK) + handler.send_header("Content-Type", "text/event-stream; charset=utf-8") + handler.send_header("Cache-Control", "no-store") + handler.send_header("X-Accel-Buffering", "no") + handler.end_headers() + try: + handler.wfile.write(f"retry: {_RETRY_MS}\n\n".encode()) + _pump(handler, job_id, after) + handler.wfile.flush() + except BrokenPipeError, ConnectionResetError: + return # The browser went away; it will reconnect if it cares. diff --git a/steam_backlog_enforcer/_web_views.py b/steam_backlog_enforcer/_web_views.py new file mode 100644 index 0000000..a3088d1 --- /dev/null +++ b/steam_backlog_enforcer/_web_views.py @@ -0,0 +1,131 @@ +"""Read-only API views: status, stats, installed games, commands, backups. + +Each builder returns the contract payload (``web/src/api/contract.ts``) and +reads only local state and caches — never ``config.json`` secrets, never the +network. They are the same numbers the CLI prints, projected to JSON. +""" + +from __future__ import annotations + +import contextlib +import re +from typing import TYPE_CHECKING, Any + +from steam_backlog_enforcer import _steam_state +from steam_backlog_enforcer._actions import allowed_app_ids +from steam_backlog_enforcer._backups import list_backups +from steam_backlog_enforcer._budget_view import build_budget_snapshot +from steam_backlog_enforcer._command_catalog import command_specs +from steam_backlog_enforcer._manual_pick_lifecycle import status_payload +from steam_backlog_enforcer._web_dataset import build_web_dataset, dataset_to_payload +from steam_backlog_enforcer._web_io import Reply, ok +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.game_install import get_installed_games, is_protected_app + +if TYPE_CHECKING: + from steam_backlog_enforcer._web_io import Request + +_SIZE_ON_DISK = re.compile(r'"SizeOnDisk"\s+"(\d+)"') + + +def _manual_pick(pick: dict[str, Any]) -> dict[str, object]: + """One contract ``ManualPickInfo`` from a ``status_payload`` pick.""" + age = pick.get("age_days") + return { + "app_id": pick["app_id"], + "name": pick.get("game_name") or str(pick["app_id"]), + # None means "start time unknown"; the contract field is a number. + "age_days": round(float(age), 1) if age is not None else 0.0, + } + + +def status_view(_request: Request) -> Reply: + """``GET /api/status`` — the ``status`` command as ``StatusPayload``.""" + payload = status_payload(State.load()) + payload["assigned_game_installed"] = bool(payload["assigned_game_installed"]) + payload["manual_picks"] = [_manual_pick(p) for p in payload["manual_picks"]] + return ok(payload) + + +def stats_view(_request: Request) -> Reply: + """``GET /api/stats`` — the MCP ``get_stats`` projection of the dataset. + + Not imported from ``_mcp_query``: that pulls in the MCP server itself. + """ + payload = dataset_to_payload(build_web_dataset(State.load())) + return ok( + { + "default_summary": payload["default_summary"], + "pace_vs_hltb": payload["pace_vs_hltb"], + } + ) + + +def _size_on_disk(app_id: int) -> int: + """``SizeOnDisk`` from the game's appmanifest (0 when unknown).""" + # Through the module: tests redirect the path after import. + manifest = _steam_state.STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" + with contextlib.suppress(OSError): + match = _SIZE_ON_DISK.search(manifest.read_text(encoding="utf-8")) + if match: + return int(match.group(1)) + return 0 + + +def installed_view(_request: Request) -> Reply: + """``GET /api/installed`` — what ``installed`` lists, with sizes. + + ``assigned`` covers every game the enforcer lets exist (the assignment + and active manual picks), the set the uninstaller spares. + """ + allowed = allowed_app_ids(State.load()) + games = [ + { + "app_id": app_id, + "name": name, + "size_bytes": _size_on_disk(app_id), + "assigned": app_id in allowed, + "protected": is_protected_app(app_id), + } + for app_id, name in get_installed_games() + ] + return ok({"games": games}) + + +def commands_view(_request: Request) -> Reply: + """``GET /api/commands`` — the catalog, locks evaluated now.""" + return ok(command_specs()) + + +def setup_view(_request: Request) -> Reply: + """``GET /api/setup`` — configured flags; the API key is never returned.""" + return ok(setup_status(Config.load())) + + +def setup_status(config: Config) -> dict[str, object]: + """The contract ``SetupStatus`` for *config*.""" + return { + "configured": bool(config.steam_api_key and config.steam_id), + "has_api_key": bool(config.steam_api_key), + "steam_id": config.steam_id or None, + } + + +def backups_view(_request: Request) -> Reply: + """``GET /api/backups`` — every state backup, newest first.""" + return ok([backup.to_json() for backup in list_backups()]) + + +def dataset_view(_request: Request) -> Reply: + """``GET /api/dataset`` — the projected backlog dataset.""" + return ok(dataset_to_payload(build_web_dataset(State.load()))) + + +def budget_view(request: Request) -> Reply: + """``GET /api/budget`` — the gaming-budget snapshot. + + ``?demo=1`` reads the demo run's state and log, which is how the + 60-second demo can be watched hitting its cutoff in the browser without + spending a real day's budget to see it. + """ + return ok(build_budget_snapshot(demo=request.query.get("demo") == "1")) diff --git a/steam_backlog_enforcer/config.py b/steam_backlog_enforcer/config.py index 34aa245..a99c50d 100644 --- a/steam_backlog_enforcer/config.py +++ b/steam_backlog_enforcer/config.py @@ -71,6 +71,11 @@ def _atomic_write(path: Path, data: str, *, mode: int | None = None) -> None: raise +# The public name, for callers outside this module. The private one predates +# it and is still what most of the package imports (and what tests patch). +atomic_write = _atomic_write + + def _field_names(cls: type) -> frozenset[str]: """The dataclass's own field names, for filtering a loaded JSON object.""" return frozenset(f.name for f in fields(cls)) diff --git a/steam_backlog_enforcer/game_uninstall.py b/steam_backlog_enforcer/game_uninstall.py index 83c84b6..843f312 100644 --- a/steam_backlog_enforcer/game_uninstall.py +++ b/steam_backlog_enforcer/game_uninstall.py @@ -14,6 +14,7 @@ import shutil from typing import TYPE_CHECKING from steam_backlog_enforcer._game_names import _is_protected_name +from steam_backlog_enforcer._progress import tracked from steam_backlog_enforcer._protected_apps import PROTECTED_APP_IDS from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH, _assert_not_real_steam from steam_backlog_enforcer._whitelist_locking import get_approved_exception_ids @@ -148,7 +149,7 @@ def uninstall_other_games(allowed_app_ids: set[int]) -> int: installed = get_installed_games() count = 0 - for app_id, name in installed: + for app_id, name in tracked(installed, "Uninstalling games", lambda g: g[1]): if app_id in allowed_app_ids: logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id) continue diff --git a/steam_backlog_enforcer/jobs/__init__.py b/steam_backlog_enforcer/jobs/__init__.py new file mode 100644 index 0000000..f1865d7 --- /dev/null +++ b/steam_backlog_enforcer/jobs/__init__.py @@ -0,0 +1,13 @@ +"""Web jobs: CLI commands run as detached subprocesses with live events. + +A job is a directory under ``~/.local/state/steam-backlog-enforcer/jobs/`` +(see ``DOCS-web-control-api.md``, "Jobs"). The web server creates it and +spawns ``python -m steam_backlog_enforcer.jobs run ``; from then on +the two only share files, so a job outlives page reloads and server restarts. + +- :mod:`._store` — create, prune and spawn jobs; the one-mutating-job lock. +- :mod:`._view` — read jobs and their events back for the API. +- :mod:`._control` — answer a prompt, cancel a job. +- :mod:`._runner` — what runs inside the subprocess. +- :mod:`._registry` — which command runs as which job. +""" diff --git a/steam_backlog_enforcer/jobs/__main__.py b/steam_backlog_enforcer/jobs/__main__.py new file mode 100644 index 0000000..0bf1e06 --- /dev/null +++ b/steam_backlog_enforcer/jobs/__main__.py @@ -0,0 +1,27 @@ +"""``python -m steam_backlog_enforcer.jobs run ``: run one web job. + +Spawned by the web server (:func:`steam_backlog_enforcer.jobs._store.create_job`); +it can also be run by hand on a prepared job dir to debug a job. +""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +from steam_backlog_enforcer.jobs._runner import run_job + + +def main(argv: list[str] | None = None) -> int: + """Parse the command line and run the job; return its exit code.""" + parser = argparse.ArgumentParser(prog="python -m steam_backlog_enforcer.jobs") + actions = parser.add_subparsers(dest="action", required=True) + run = actions.add_parser("run", help="run the job in JOB_DIR") + run.add_argument("job_dir", type=Path) + args = parser.parse_args(argv) + return run_job(args.job_dir) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/steam_backlog_enforcer/jobs/_control.py b/steam_backlog_enforcer/jobs/_control.py new file mode 100644 index 0000000..e8a9015 --- /dev/null +++ b/steam_backlog_enforcer/jobs/_control.py @@ -0,0 +1,90 @@ +"""Act on a running job: answer its prompt, or cancel it. + +Both only touch files and signals the job already watches, so they work +the same whether the job was started by this server process or a previous +one. +""" + +from __future__ import annotations + +import os +import signal + +from steam_backlog_enforcer.jobs import _store +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._events import ( + ANSWERS_NAME, + CANCEL_NAME, + TERMINAL_STATES, + append_jsonl, + read_events, + read_jsonl, +) +from steam_backlog_enforcer.jobs._specs import JOB_FLAGS +from steam_backlog_enforcer.jobs._view import pid_of, read_job + + +def _open_prompt_id(job_id: str) -> str | None: + """The id of the prompt the job is waiting on, if it is waiting.""" + path = _store.job_dir(job_id) + prompts = [e for e in read_events(path) if e.get("type") == "prompt"] + if not prompts: + return None + answered = {a.get("prompt_id") for a in read_jsonl(path / ANSWERS_NAME)} + latest = str(prompts[-1]["prompt_id"]) + return None if latest in answered else latest + + +def write_answer(job_id: str, prompt_id: str, value: str) -> None: + """Deliver the user's answer to the prompt the job is waiting on. + + The answer is not judged here — the job re-checks it — but it must be + for the prompt that is actually open, so a stale or replayed answer + cannot land on a later question. + + Raises: + JobError: ``not_found`` for an unknown job; ``invalid_params`` for a + finished job or a ``prompt_id`` that is not the open prompt. + """ + job = read_job(job_id) + if job["state"] in TERMINAL_STATES: + msg = f"Job {job_id} has already finished." + raise JobError(msg, code="invalid_params") + if _open_prompt_id(job_id) != prompt_id: + msg = f"Prompt {prompt_id!r} is not waiting for an answer." + raise JobError(msg, code="invalid_params") + path = _store.job_dir(job_id) + append_jsonl(path / ANSWERS_NAME, {"prompt_id": prompt_id, "value": value}) + + +def cancel(job_id: str) -> None: + """Ask a job to stop: any job waiting on a prompt, else cancellable ones. + + A cancellable job gets SIGTERM, to its own process only. + + Only the job's pid is signalled, never its process group: a job may + have started Steam, and cancelling a scan must not take Steam down. + + Raises: + JobError: ``not_cancellable`` for a command that is unsafe to stop + midway; ``not_found`` for an unknown job; ``invalid_params`` for + a finished one. + """ + job = read_job(job_id) + if job["state"] == "waiting_input": + # Blocked on a prompt, so nothing is half-done: any command may stop + # here. The job's own prompt loop sees the marker and cancels itself. + (_store.job_dir(job_id) / CANCEL_NAME).touch() + return + flags = JOB_FLAGS.get(job["command"]) + if flags is None or not flags.cancellable: + msg = f"{job['command']} cannot be cancelled once started." + raise JobError(msg, code="not_cancellable") + if job["state"] in TERMINAL_STATES: + msg = f"Job {job_id} has already finished." + raise JobError(msg, code="invalid_params") + pid = pid_of(_store.job_dir(job_id)) + if pid is None: + msg = f"Job {job_id} has no running process." + raise JobError(msg, code="invalid_params") + os.kill(pid, signal.SIGTERM) diff --git a/steam_backlog_enforcer/jobs/_daemon_jobs.py b/steam_backlog_enforcer/jobs/_daemon_jobs.py new file mode 100644 index 0000000..83fcfc5 --- /dev/null +++ b/steam_backlog_enforcer/jobs/_daemon_jobs.py @@ -0,0 +1,88 @@ +"""Record privileged actions, carried out by the root daemon, as jobs. + +A daemon op is one blocking socket call, not a subprocess, so it has no pid +for :mod:`._view` to watch. The job dir is therefore written only once the +call has returned, all at once: ``request.json``, the full event sequence +(``queued`` → ``running`` → ``log`` → ``result`` → final ``state``) and +``result.json``, the same files a real job leaves behind. Written earlier, +a 10-minute ``block_gaming`` would be failed by the dead-pid reconciler +half-way through and then get a second, contradictory result. + +The phrase the user typed is never stored: it is not a secret, but it is +also not something history needs. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import json +from typing import TYPE_CHECKING, Any + +from steam_backlog_enforcer.config import _atomic_write +from steam_backlog_enforcer.jobs import _store +from steam_backlog_enforcer.jobs._events import REQUEST_NAME, RESULT_NAME, EventWriter + +if TYPE_CHECKING: + from collections.abc import Mapping + +EXIT_OK = 0 +EXIT_FAILED = 1 +EXIT_REFUSED = 2 + + +@dataclass(frozen=True) +class DaemonOutcome: + """How a daemon op ended, as the job records it. + + Attributes: + ok: Whether the op did what was asked. + summary: One line for the job list and the ``result`` event. + exit_code: ``0`` ok, ``1`` failed, ``2`` refused. + data: Optional result data for the ``result`` event. + """ + + ok: bool + summary: str + exit_code: int + data: Mapping[str, Any] | None = None + + +def record_daemon_job( + command: str, + params: Mapping[str, object], + *, + created_at: str, + outcome: DaemonOutcome, +) -> str: + """Write a finished job for a daemon op and return its id. + + Args: + command: The contract command name (``unblock``, ``gaming-reset``…). + params: The validated params the op ran with. + created_at: ISO time the request arrived (before the daemon call). + outcome: How the op ended. + """ + ok, summary, data = outcome.ok, outcome.summary, outcome.data + _store.prune(_store.MAX_JOBS - 1) + path = _store.JOBS_DIR / _store.new_job_id() + path.mkdir(parents=True, mode=0o700) + request = { + "command": command, + "params": dict(params), + "created_at": created_at, + "via": "daemon", + } + (path / REQUEST_NAME).write_text(json.dumps(request) + "\n", encoding="utf-8") + writer = EventWriter(path) + state = "succeeded" if ok else "failed" + writer.emit("state", state="queued") + writer.emit("state", state="running") + writer.emit("log", level="info" if ok else "error", message=summary) + result: dict[str, Any] = {"ok": ok, "summary": summary} + if data is not None: + result["data"] = json.loads(json.dumps(dict(data), default=str)) + writer.emit("result", **result) + final = result | {"state": state, "exit_code": outcome.exit_code} + _atomic_write(path / RESULT_NAME, json.dumps(final, indent=2) + "\n") + writer.emit("state", state=state) + return path.name diff --git a/steam_backlog_enforcer/jobs/_errors.py b/steam_backlog_enforcer/jobs/_errors.py new file mode 100644 index 0000000..e3b80d6 --- /dev/null +++ b/steam_backlog_enforcer/jobs/_errors.py @@ -0,0 +1,48 @@ +"""Errors the job API raises, carrying the contract's ``ApiErrorCode``.""" + +from __future__ import annotations + +from typing import Literal + +JobErrorCode = Literal[ + "unknown_command", + "invalid_params", + "not_found", + "wrong_phrase", + "locked", + "busy", + "not_cancellable", + "op_failed", +] + + +class JobError(Exception): + """A job request the server must refuse (4xx; 502 for ``op_failed``). + + Attributes: + code: The contract's ``ApiErrorCode`` for the response body. + message: Human text for the response body. + """ + + def __init__(self, message: str, *, code: JobErrorCode) -> None: + """Store the code next to the message.""" + super().__init__(message) + self.code: JobErrorCode = code + self.message = message + + +class PrivilegedViaDaemonError(Exception): + """The command needs root, so it runs in the daemon, never as a job. + + The web server catches this and routes the request to the control + socket instead (``DOCS-web-control-api.md``, "Control socket"). + """ + + def __init__(self, command: str) -> None: + """Name the command that has to go through the daemon.""" + super().__init__(f"{command} runs in the root daemon, not as a web job.") + self.command = command + + +class JobCancelledError(Exception): + """Raised inside a cancellable job when it receives SIGTERM.""" diff --git a/steam_backlog_enforcer/jobs/_events.py b/steam_backlog_enforcer/jobs/_events.py new file mode 100644 index 0000000..fd9660f --- /dev/null +++ b/steam_backlog_enforcer/jobs/_events.py @@ -0,0 +1,117 @@ +"""The files of one job directory and the JSONL event log inside it. + +``events.jsonl`` is append-only, one ``JobEvent`` per line, ``seq`` strictly +increasing. Normally only the job subprocess appends; the server appends +too in two cases (the initial ``queued`` state, and the ``failed`` verdict +for a job whose process died), so every append takes an exclusive ``flock`` +and recomputes ``seq`` whenever someone else wrote since. + +Readers only ever parse complete, newline-terminated lines: a line still +being written is invisible until it is whole. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import fcntl +import json +import os +import threading +from typing import TYPE_CHECKING, Any, Final + +if TYPE_CHECKING: + from pathlib import Path + +REQUEST_NAME: Final = "request.json" +EVENTS_NAME: Final = "events.jsonl" +ANSWERS_NAME: Final = "answers.jsonl" +# Touched by the server to cancel a job that is blocked on a prompt. +CANCEL_NAME: Final = "cancel.request" +RESULT_NAME: Final = "result.json" +PID_NAME: Final = "pid" +OUTPUT_NAME: Final = "output.log" + +TERMINAL_STATES: Final = frozenset({"succeeded", "failed", "cancelled"}) + + +def now_iso() -> str: + """Current UTC time as ISO-8601 with a ``Z`` suffix, second precision.""" + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def read_jsonl(path: Path) -> list[dict[str, Any]]: + """Parse every complete line of a JSONL file; skip torn or bad lines.""" + try: + raw = path.read_text(encoding="utf-8") + except FileNotFoundError: + return [] + complete = raw if raw.endswith("\n") else raw[: raw.rfind("\n") + 1] + out: list[dict[str, Any]] = [] + for line in complete.splitlines(): + try: + obj = json.loads(line) + except ValueError: + continue + if isinstance(obj, dict): + out.append(obj) + return out + + +def append_jsonl(path: Path, obj: dict[str, Any]) -> None: + """Append one object as a line, under an exclusive lock.""" + with path.open("a", encoding="utf-8") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + fh.write(json.dumps(obj) + "\n") + fh.flush() + + +def read_events(job_dir: Path, after_seq: int = 0) -> list[dict[str, Any]]: + """Return the job's events with ``seq`` greater than *after_seq*.""" + return [ + event + for event in read_jsonl(job_dir / EVENTS_NAME) + if isinstance(event.get("seq"), int) and event["seq"] > after_seq + ] + + +class EventWriter: + """Appends events to one job's ``events.jsonl`` with gap-free ``seq``.""" + + def __init__(self, job_dir: Path) -> None: + """Bind to *job_dir*; the file is created on the first event.""" + self._path = job_dir / EVENTS_NAME + self._seq = 0 + self._size = -1 # File size after our last write; -1 = never wrote. + self._lock = threading.Lock() + + def _last_seq(self) -> int: + """The highest ``seq`` already in the file (0 if none).""" + seqs = [ + e["seq"] for e in read_jsonl(self._path) if isinstance(e.get("seq"), int) + ] + return max(seqs, default=0) + + def emit(self, event_type: str, **fields: object) -> dict[str, Any]: + """Append one event and return it. + + Args: + event_type: ``state``, ``log``, ``progress``, ``prompt`` or + ``result``. + **fields: The type-specific fields from the contract. + """ + with self._lock, self._path.open("a", encoding="utf-8") as fh: + fcntl.flock(fh, fcntl.LOCK_EX) + if os.fstat(fh.fileno()).st_size != self._size: + # First write, or another process appended since our last one. + self._seq = self._last_seq() + self._seq += 1 + event: dict[str, Any] = { + "seq": self._seq, + "ts": now_iso(), + "type": event_type, + **fields, + } + fh.write(json.dumps(event) + "\n") + fh.flush() + self._size = os.fstat(fh.fileno()).st_size + return event diff --git a/steam_backlog_enforcer/jobs/_job_io.py b/steam_backlog_enforcer/jobs/_job_io.py new file mode 100644 index 0000000..a90f77c --- /dev/null +++ b/steam_backlog_enforcer/jobs/_job_io.py @@ -0,0 +1,143 @@ +"""Turn a command's output and progress into job events. + +Inside a job the command code is unchanged: it still calls ``_echo``, +``current_progress()`` and ``logging``. The runner installs the three +adapters here, which write ``log`` and ``progress`` events instead of a +terminal. +""" + +from __future__ import annotations + +import logging +import re +import time +from typing import TYPE_CHECKING, Literal + +from steam_backlog_enforcer._progress import eta_seconds + +if TYPE_CHECKING: + from steam_backlog_enforcer.jobs._events import EventWriter + +LogLevel = Literal["info", "warning", "error"] + +# At most this many progress events per second, plus every phase start and +# the final step: enough for a smooth bar, cheap for SSE and the event fold. +_MIN_PROGRESS_INTERVAL = 0.2 +_CONTROL = re.compile(r"(\r|\n)") + + +def _level_for(line: str) -> LogLevel: + """Guess a CLI line's severity from how the commands phrase it.""" + lowered = line.lower() + if lowered.startswith(("error", "failed")): + return "error" + if lowered.startswith(("warning", "⚠")) or "⚠" in line: + return "warning" + return "info" + + +class JobLogSink: + """An ``_echo`` sink that behaves like a terminal, one event per line. + + A carriage return discards the unfinished line, exactly as a terminal + overwrites it, so the CLI's carriage-return progress meters collapse into the + single line they finally show instead of thousands of events. + """ + + def __init__(self, writer: EventWriter) -> None: + """Bind to the job's event writer.""" + self._writer = writer + self._buffer = "" + self.last_line: str | None = None + """The last line with any text: the job's default summary.""" + self.last_error: str | None = None + """The last error-level line, if any: a printed failure.""" + + def __call__(self, text: str) -> None: + """Consume raw ``_echo`` output (message plus ``end``).""" + for part in _CONTROL.split(text): + if part == "\n": + self.flush() + elif part == "\r": + self._buffer = "" + else: + self._buffer += part + + def flush(self) -> None: + """Emit the buffered line, if it has any visible text.""" + line = self._buffer.strip() + self._buffer = "" + if line: + if any(ch.isalnum() for ch in line): # Not a "────" rule. + self.last_line = line + level = _level_for(line) + if level == "error": + self.last_error = line + self._writer.emit("log", level=level, message=line) + + +class JobProgress: + """A :class:`~steam_backlog_enforcer._progress.Progress` writing events.""" + + def __init__(self, writer: EventWriter) -> None: + """Bind to the job's event writer.""" + self._writer = writer + self._label = "" + self._total: int | None = None + self._step = 0 + self._item: str | None = None + self._started = time.monotonic() + self._last_emit = 0.0 + + def phase(self, label: str, total: int | None = None) -> None: + """Start a phase and announce it at once.""" + self._label, self._total, self._step, self._item = label, total, 0, None + self._started = time.monotonic() + self._emit(force=True) + + def advance(self, item: str | None = None, *, step: int | None = None) -> None: + """Record a step; emitted unless one went out a moment ago.""" + self._step = self._step + 1 if step is None else step + self._item = item + done = self._total is not None and self._step >= self._total + self._emit(force=done) + + def _emit(self, *, force: bool) -> None: + """Write a ``progress`` event, throttled unless *force*.""" + now = time.monotonic() + if not force and now - self._last_emit < _MIN_PROGRESS_INTERVAL: + return + self._last_emit = now + fields: dict[str, object] = { + "step": self._step, + "total": self._total, + "label": self._label, + } + if self._item: + fields["item"] = self._item + eta = eta_seconds(now - self._started, self._step, self._total) + if eta is not None: + fields["eta_seconds"] = eta + self._writer.emit("progress", **fields) + + +class EventLogHandler(logging.Handler): + """Forwards WARNING-and-above log records as ``log`` events. + + INFO chatter stays in the job's ``output.log``; what a user should see + (a failed uninstall, an unreachable Steam) reaches the UI. + """ + + def __init__(self, writer: EventWriter) -> None: + """Bind to the job's event writer.""" + super().__init__(level=logging.WARNING) + self._writer = writer + + def emit(self, record: logging.LogRecord) -> None: + """Write one record as a ``log`` event.""" + level: LogLevel = "error" if record.levelno >= logging.ERROR else "warning" + try: + message = record.getMessage() + except TypeError, ValueError: + message = str(record.msg) + self._writer.emit("log", level=level, message=message) diff --git a/steam_backlog_enforcer/jobs/_job_prompter.py b/steam_backlog_enforcer/jobs/_job_prompter.py new file mode 100644 index 0000000..d0a6bbf --- /dev/null +++ b/steam_backlog_enforcer/jobs/_job_prompter.py @@ -0,0 +1,172 @@ +"""The prompter a job uses: a ``prompt`` event out, an answer line back. + +The job writes a ``prompt`` event and blocks, polling ``answers.jsonl`` +(appended by the server on ``POST /api/jobs/{id}/answer``) for a line with +its ``prompt_id``. Nothing the UI sends is trusted: a choice must be one of +the offered values and a phrase is re-checked with +:func:`steam_backlog_enforcer._friction.phrase_matches` right here, in the +process that carries the action out. +""" + +from __future__ import annotations + +import secrets +import time +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._friction import phrase_matches +from steam_backlog_enforcer._prompter import PromptAbortedError +from steam_backlog_enforcer.jobs._errors import JobCancelledError +from steam_backlog_enforcer.jobs._events import ANSWERS_NAME, CANCEL_NAME, read_jsonl + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + + from steam_backlog_enforcer._prompter import PromptOption + from steam_backlog_enforcer.jobs._events import EventWriter + +# An unanswered prompt holds the mutating-job lock; give up eventually. +PROMPT_TIMEOUT_SECONDS: Final = 3600 +MAX_PHRASE_ATTEMPTS: Final = 3 +_POLL_SECONDS = 0.2 +_YES = frozenset({"y", "yes", "true", "1"}) +_NO = frozenset({"n", "no", "false", "0"}) + + +class JobPrompter: + """A :class:`~steam_backlog_enforcer._prompter.Prompter` over job files.""" + + def __init__( + self, + writer: EventWriter, + job_dir: Path, + *, + preset_phrase: str | None = None, + timeout: float = PROMPT_TIMEOUT_SECONDS, + ) -> None: + """Bind to a job. + + Args: + writer: The job's event writer. + job_dir: Where ``answers.jsonl`` appears. + preset_phrase: ``JobRequest.confirm_phrase``, typed up front. The + first phrase prompt consumes it if it matches, and asks + normally if it does not. + timeout: Seconds to wait for any one answer. + """ + self._writer = writer + self._answers = job_dir / ANSWERS_NAME + self._cancel = job_dir / CANCEL_NAME + self._preset = preset_phrase + self._timeout = timeout + + @property + def interactive(self) -> bool: + """A job always has the UI to ask.""" + return True + + def _wait(self, prompt_id: str) -> str: + """Block until the answer for *prompt_id* arrives. + + Raises: + JobCancelledError: The user cancelled while the job was waiting. + Safe for every command, cancellable or not: a job blocked on + a question is where the CLI's Ctrl-C would land. + """ + deadline = time.monotonic() + self._timeout + while time.monotonic() < deadline: + if self._cancel.exists(): + raise JobCancelledError + for answer in read_jsonl(self._answers): + if answer.get("prompt_id") == prompt_id: + return str(answer.get("value", "")) + time.sleep(_POLL_SECONDS) + msg = f"No answer within {self._timeout / 60:.0f} minute(s)." + raise PromptAbortedError(msg) + + def _ask(self, kind: str, message: str, **extra: object) -> str: + """Emit one prompt, wait for its answer, and return it.""" + prompt_id = secrets.token_hex(8) + self._writer.emit("state", state="waiting_input") + self._writer.emit( + "prompt", prompt_id=prompt_id, kind=kind, message=message.strip(), **extra + ) + value = self._wait(prompt_id) + self._writer.emit("state", state="running") + return value + + def _warn(self, message: str) -> None: + """Tell the user why their answer was not accepted.""" + self._writer.emit("log", level="warning", message=message) + + def choice(self, message: str, options: Sequence[PromptOption]) -> str: + """Ask until the answer is one of the offered values.""" + payload = [ + {"value": o.value, "label": o.label} + | ({"detail": o.detail} if o.detail else {}) + for o in options + ] + valid = {o.value for o in options} + while True: + value = self._ask("choice", message, options=payload) + if value in valid: + return value + self._warn(f"Not one of the options: {value!r}") + + def confirm(self, message: str, *, default: bool) -> bool: + """Ask yes/no; an empty answer means *default*.""" + while True: + value = self._ask( + "confirm", message, default="yes" if default else "no" + ).strip() + if not value: + return default + if value.lower() in _YES: + return True + if value.lower() in _NO: + return False + self._warn(f"Answer yes or no, not {value!r}") + + def phrase(self, message: str, expected: str) -> bool: + """Return True once *expected* is typed exactly. + + Raises: + PromptAbortedError: On an empty answer (declined) or after + :data:`MAX_PHRASE_ATTEMPTS` wrong ones. A job never treats a + wrong phrase as a quiet "no": it fails, visibly. + """ + preset, self._preset = self._preset, None + if preset is not None: + if phrase_matches(expected, preset): + return True + self._warn("The phrase sent with the request did not match.") + for _ in range(MAX_PHRASE_ATTEMPTS): + value = self._ask("phrase", message, phrase=expected) + if not value.strip(): + msg = "Declined." + raise PromptAbortedError(msg) + if phrase_matches(expected, value): + return True + self._warn(f'That is not the phrase. Type exactly: "{expected}"') + msg = f"Wrong phrase {MAX_PHRASE_ATTEMPTS} times; nothing was changed." + raise PromptAbortedError(msg) + + def text(self, message: str) -> str: + """Ask for one line of free text.""" + return self._ask("text", message).strip() + + def game(self, message: str, app_ids: Sequence[int]) -> int | None: + """Ask for a game from the library browser; empty means go back. + + The event carries only the ids (the UI already has the library); the + answer must be one of them, whatever the browser let the user click. + """ + valid = frozenset(app_ids) + while True: + value = self._ask("game", message, app_ids=sorted(valid)).strip() + if not value: + return None + if value.isdigit() and int(value) in valid: + return int(value) + self._warn(f"Not a game you can pick here: {value!r}") diff --git a/steam_backlog_enforcer/jobs/_registry.py b/steam_backlog_enforcer/jobs/_registry.py new file mode 100644 index 0000000..e8c719a --- /dev/null +++ b/steam_backlog_enforcer/jobs/_registry.py @@ -0,0 +1,166 @@ +"""Which command runs as which job, and how. + +Every handler is the CLI command itself, run under the job's output sink, +progress reporter and prompter (installed by :mod:`._runner`), so a job +cannot drift from what ``./run.sh `` does. Params arrive validated +(:mod:`steam_backlog_enforcer._command_params`) and are turned back into +the argument list the CLI function already parses. + +Privileged commands are registered too, with a handler that refuses: they +run in the root daemon, and the web server routes them there before a job +would ever be created (see :func:`._specs.is_privileged`). + +Only the job subprocess (:mod:`._runner`) imports this module: it pulls in +every command, and the web server must not (see :mod:`._specs`). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._backups import restore_backup +from steam_backlog_enforcer._cmd_playtime import cmd_enforce +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._prompter import PromptAbortedError, confirm_phrase +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.jobs._errors import PrivilegedViaDaemonError +from steam_backlog_enforcer.main import ( + cmd_abandon_pick, + cmd_add_exception, + cmd_done, + cmd_hide, + cmd_install, + cmd_list, + cmd_pick, + cmd_pick_manual, + cmd_reset, + cmd_stats, + cmd_unhide, + cmd_uninstall, +) +from steam_backlog_enforcer.scanning import do_check, do_scan + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + from steam_backlog_enforcer._command_params import ParamValue + from steam_backlog_enforcer._progress import Progress + from steam_backlog_enforcer._prompter import Prompter + +type JobResult = dict[str, object] | None +type JobHandler = Callable[[Mapping[str, ParamValue], Progress, Prompter], JobResult] + + +def _assignment(state: State) -> dict[str, object]: + """The result data every backlog job returns: what is assigned now.""" + return { + "current_app_id": state.current_app_id, + "current_game_name": state.current_game_name or None, + } + + +def _cli(command: Callable[[Config, State], object]) -> JobHandler: + """Wrap a ``(config, state)`` CLI command as a job handler.""" + + def handler( + _params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter + ) -> JobResult: + state = State.load() + command(Config.load(), state) + return _assignment(state) + + return handler + + +def _with_args( + command: Callable[[Config, State, list[str]], object], param: str +) -> JobHandler: + """Wrap a ``(config, state, args)`` command taking one positional arg.""" + + def handler( + params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter + ) -> JobResult: + state = State.load() + command(Config.load(), state, [str(params[param])]) + return _assignment(state) + + return handler + + +def _add_exception( + params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter +) -> JobResult: + """``add-exception --reason ``.""" + cmd_add_exception([str(params["app_id"]), "--reason", str(params["reason"])]) + return {"app_id": params["app_id"]} + + +def _enforce_demo( + params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter +) -> JobResult: + """``enforce --demo``: the 60-second budget, until cancelled.""" + if not params.get("demo"): + command = "enforce" + raise PrivilegedViaDaemonError(command) + return {"exit_code": cmd_enforce(Config.load(), State.load(), ["--demo"])} + + +def _restore_backup( + params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter +) -> JobResult: + """Replace ``state.json`` with a backup, after the typed phrase.""" + backup_id = str(params["backup_id"]) + if not confirm_phrase( + "restore-backup", + f"Replace the current state with backup {backup_id}?", + backup_id=backup_id, + ): + msg = "Aborted." + raise PromptAbortedError(msg) + previous = restore_backup(backup_id) + _echo(f"Restored backup {backup_id}.") + if previous is not None: + _echo(f"The replaced state was saved as backup {previous.id}.") + return {"restored": backup_id, "previous_backup": previous and previous.id} + + +def _via_daemon(command: str) -> JobHandler: + """A placeholder for a privileged command: it always refuses.""" + + def handler( + _params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter + ) -> JobResult: + raise PrivilegedViaDaemonError(command) + + return handler + + +# (params, progress, prompter) -> result data, one per job command; the +# lock/cancel/privilege flags for the same commands are in _specs.JOB_FLAGS. +HANDLERS: dict[str, JobHandler] = { + "check": _cli(do_check), + "scan": _cli(do_scan), + "done": _cli(cmd_done), + "pick": _cli(cmd_pick), + "pick-manual": _with_args(cmd_pick_manual, "app_id"), + "abandon-pick": _with_args(cmd_abandon_pick, "app_id"), + "install": _cli(cmd_install), + "uninstall": _cli(cmd_uninstall), + "hide": _cli(cmd_hide), + "unhide": _cli(cmd_unhide), + "add-exception": _add_exception, + "reset": _cli(cmd_reset), + "restore-backup": _restore_backup, + "stats": _cli(cmd_stats), + "list": _cli(cmd_list), + "enforce": _enforce_demo, +} | { + command: _via_daemon(command) + for command in ( + "gaming-reset", + "gaming-unblock", + "block-gaming", + "unblock", + "buy-dlc", + ) +} diff --git a/steam_backlog_enforcer/jobs/_runner.py b/steam_backlog_enforcer/jobs/_runner.py new file mode 100644 index 0000000..337cc7e --- /dev/null +++ b/steam_backlog_enforcer/jobs/_runner.py @@ -0,0 +1,215 @@ +"""What runs inside a job subprocess: one command, start to result. + +``python -m steam_backlog_enforcer.jobs run `` lands here. The +runner passes the same lock gate as the CLI, runs the command with the job's +output sink, progress reporter and prompter installed, and always ends with +exactly one ``result`` event, ``result.json`` and a final ``state`` event — +whether the command succeeded, refused, raised, exited or was cancelled. +""" + +from __future__ import annotations + +from contextlib import ExitStack +from dataclasses import dataclass +import fcntl +import json +import logging +import os +import signal +from typing import TYPE_CHECKING, Any, Final + +from steam_backlog_enforcer._command_gate import lock_reason +from steam_backlog_enforcer._echo import routed_echo +from steam_backlog_enforcer._progress import use_progress +from steam_backlog_enforcer._prompter import PromptAbortedError, use_prompter +from steam_backlog_enforcer.config import Config, State, _atomic_write +from steam_backlog_enforcer.jobs import _spawn, _store +from steam_backlog_enforcer.jobs._errors import ( + JobCancelledError, + JobError, + PrivilegedViaDaemonError, +) +from steam_backlog_enforcer.jobs._events import ( + PID_NAME, + REQUEST_NAME, + RESULT_NAME, + EventWriter, +) +from steam_backlog_enforcer.jobs._job_io import EventLogHandler, JobLogSink, JobProgress +from steam_backlog_enforcer.jobs._job_prompter import JobPrompter +from steam_backlog_enforcer.jobs._registry import HANDLERS +from steam_backlog_enforcer.jobs._specs import JOB_FLAGS, JobFlags +from steam_backlog_enforcer.main._gate import enforce_gate + +if TYPE_CHECKING: + from pathlib import Path + from types import FrameType + +logger = logging.getLogger(__name__) + +EXIT_OK = 0 +EXIT_FAILED = 1 +EXIT_REFUSED = 2 +EXIT_CANCELLED = 130 +# The job lock fd is ours alone: anything the job spawns must not keep the +# lock alive after we exit. (``os.set_inheritable`` takes it positionally.) +_CHILDREN_INHERIT_LOCK: Final = False + + +@dataclass +class _Outcome: + """How the job ended; becomes the ``result`` event and ``result.json``.""" + + state: str + summary: str + exit_code: int + data: Any = None + + @property + def ok(self) -> bool: + """Whether the command did what was asked.""" + return self.state == "succeeded" + + +def _failed(summary: str, exit_code: int = EXIT_FAILED) -> _Outcome: + """A failed outcome.""" + return _Outcome("failed", summary, exit_code) + + +def _hold_lock(flags: JobFlags) -> int | None: + """Hold the one-mutating-job lock for the life of this process. + + Spawned by the server, the lock is already ours (inherited descriptor); + run by hand, it is taken here. Either way it is made non-inheritable so + a program the job starts (Steam) cannot keep it after the job ends. + + The inherited descriptor is checked against the lock file itself, not + trusted by number: were it closed or reused on the way in (a wrapper like + ``systemd-run``), the busy guarantee would silently vanish. + + Raises: + JobError: ``busy`` when another job holds the lock, or the inherited + descriptor is not the lock file. + """ + if not flags.mutating: + return None + inherited = os.environ.pop(_spawn.LOCK_FD_ENV, None) + if inherited is None: + fd = _store.acquire_mutating_lock() + else: + fd = int(inherited) + lock_file = (_store.JOBS_DIR / _store.LOCK_NAME).stat() + held = os.fstat(fd) + if (held.st_dev, held.st_ino) != (lock_file.st_dev, lock_file.st_ino): + msg = "The inherited job lock is not the lock file; refusing to run." + raise JobError(msg, code="busy") + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) # Ours already: no-op. + os.set_inheritable(fd, _CHILDREN_INHERIT_LOCK) + return fd + + +def _on_sigterm(*, cancellable: bool) -> None: + """Install the SIGTERM policy: cancel if that is safe, else ignore it. + + The handler does no I/O: a signal landing mid-``emit`` would otherwise + deadlock on the event writer's own lock. + """ + + def cancel(_signum: int, _frame: FrameType | None) -> None: + raise JobCancelledError + + signal.signal(signal.SIGTERM, cancel if cancellable else signal.SIG_IGN) + + +def _completed(sink: JobLogSink, data: object = None) -> _Outcome: + """The outcome of a command that returned (or exited 0) normally. + + Many commands report a failure by printing ``Error: ...`` and returning; + such a run is a failure, with that line as its summary. + """ + sink.flush() + if sink.last_error is not None: + return _failed(sink.last_error) + return _Outcome("succeeded", sink.last_line or "Done.", EXIT_OK, data) + + +def _run_command( + job_dir: Path, writer: EventWriter, sink: JobLogSink, request: dict[str, Any] +) -> _Outcome: + """Gate and run the requested command under the job's I/O adapters.""" + command = str(request["command"]) + params = dict(request.get("params", {})) + flags, job = JOB_FLAGS.get(command), HANDLERS.get(command) + if flags is None or job is None: + return _failed(f"{command!r} does not run as a job.", EXIT_REFUSED) + _hold_lock(flags) + writer.emit("state", state="running") + progress = JobProgress(writer) + prompter = JobPrompter(writer, job_dir, preset_phrase=request.get("confirm_phrase")) + handler = EventLogHandler(writer) + with ExitStack() as stack: + stack.enter_context(routed_echo(sink)) + stack.enter_context(use_progress(progress)) + stack.enter_context(use_prompter(prompter)) + logging.getLogger().addHandler(handler) + stack.callback(logging.getLogger().removeHandler, handler) + _on_sigterm(cancellable=flags.cancellable) + stack.callback(signal.signal, signal.SIGTERM, signal.SIG_IGN) + + config = Config.load() + try: + enforce_gate(command, config) + except SystemExit: + # The gate already printed its banner (now log events); the + # summary is the one-line form of the same refusal. + sink.flush() + reason = lock_reason(command, config, State.load()) + return _failed(reason or "Refused by the lock gate.", EXIT_REFUSED) + try: + data = job(params, progress, prompter) + except SystemExit as exc: + if exc.code in {0, None}: + return _completed(sink) + sink.flush() + return _failed(sink.last_line or f"Exited with status {exc.code}.") + return _completed(sink, data) + + +def _finish(job_dir: Path, writer: EventWriter, outcome: _Outcome) -> None: + """Write the single result: event, ``result.json``, then final state.""" + signal.signal(signal.SIGTERM, signal.SIG_IGN) # Never die half-written. + fields: dict[str, Any] = {"ok": outcome.ok, "summary": outcome.summary} + if outcome.data is not None: + fields["data"] = json.loads(json.dumps(outcome.data, default=str)) + writer.emit("result", **fields) + result = fields | {"state": outcome.state, "exit_code": outcome.exit_code} + _atomic_write(job_dir / RESULT_NAME, json.dumps(result, indent=2) + "\n") + writer.emit("state", state=outcome.state) + + +def run_job(job_dir: Path) -> int: + """Run the job in *job_dir* to completion and return its exit code.""" + job_dir = job_dir.resolve() + writer = EventWriter(job_dir) + sink = JobLogSink(writer) + (job_dir / PID_NAME).write_text(f"{os.getpid()}\n", encoding="utf-8") + try: + request = json.loads((job_dir / REQUEST_NAME).read_text(encoding="utf-8")) + outcome = _run_command(job_dir, writer, sink, request) + except JobCancelledError, KeyboardInterrupt: + sink.flush() + outcome = _Outcome("cancelled", "Cancelled.", EXIT_CANCELLED) + except PromptAbortedError as exc: + outcome = _failed(str(exc) or "Aborted.") + except JobError as exc: + outcome = _failed(exc.message, EXIT_REFUSED) + except PrivilegedViaDaemonError as exc: + outcome = _failed(str(exc), EXIT_REFUSED) + except Exception as exc: + logger.exception("Job %s crashed", job_dir.name) + outcome = _failed(f"{type(exc).__name__}: {exc}") + except BaseException as exc: # SystemExit from outside a handler, etc. + logger.exception("Job %s stopped", job_dir.name) + outcome = _failed(f"{type(exc).__name__}: {exc}") + _finish(job_dir, writer, outcome) + return outcome.exit_code diff --git a/steam_backlog_enforcer/jobs/_spawn.py b/steam_backlog_enforcer/jobs/_spawn.py new file mode 100644 index 0000000..41048fd --- /dev/null +++ b/steam_backlog_enforcer/jobs/_spawn.py @@ -0,0 +1,147 @@ +"""Start a job's detached process, and notice at once when it never started. + +Under systemd the job runs through ``systemd-run --user --scope``. When that +cannot register the scope (no user bus, a bad ``XDG_RUNTIME_DIR``) it exits +straight away and the job never runs — yet ``Popen`` succeeded, so without a +check the job would sit ``queued`` until the dead-pid reconciler failed it +30 s later. :func:`spawn_job` waits the few milliseconds it takes to see +either the job's own interpreter (``systemd-run`` execs into it in place) or +the launcher's exit, and fails the job with the launcher's output instead. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time +from typing import Final + +from steam_backlog_enforcer.config import _atomic_write +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._events import ( + OUTPUT_NAME, + RESULT_NAME, + EventWriter, + read_events, +) + +LOCK_FD_ENV: Final = "SBE_JOB_LOCK_FD" +_PACKAGE_ROOT = Path(__file__).resolve().parents[2] +# How long to wait for the launcher to exec into the job (or die). A healthy +# systemd-run takes milliseconds; past this the reconciler takes over. +_CONFIRM_SECONDS: Final = 3.0 +_POLL_SECONDS: Final = 0.02 +_OUTPUT_TAIL_CHARS: Final = 600 +_EXIT_SPAWN_FAILED: Final = 1 + + +def _spawn_argv(path: Path) -> list[str]: + """The job subprocess command line. + + Under systemd (the web server's user unit) the job is moved into its own + transient scope, so restarting the server's unit — which kills its + whole cgroup — does not take running jobs down with it. + """ + argv = [sys.executable, "-m", "steam_backlog_enforcer.jobs", "run", str(path)] + systemd_run = shutil.which("systemd-run") + if os.environ.get("INVOCATION_ID") and systemd_run: + unit = f"sbe-job-{path.name.lower()}" + argv = [systemd_run, "--user", "--scope", "--quiet", "--collect", + f"--unit={unit}", "--", *argv] # fmt: skip + return argv + + +def _is_job_interpreter(pid: int) -> bool: + """Whether *pid* is already the job's Python (not still the launcher).""" + try: + argv0 = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0", 1)[0] + except OSError: + return False + return argv0 == os.fsencode(sys.executable) + + +def _output_tail(path: Path) -> str: + """The end of the job's captured stdout/stderr, for the failure log.""" + try: + text = (path / OUTPUT_NAME).read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + return text.strip()[-_OUTPUT_TAIL_CHARS:] + + +def _fail_unstarted(path: Path, summary: str) -> JobError: + """Fail a job whose process never started; return the error to raise.""" + tail = _output_tail(path) + writer = EventWriter(path) + writer.emit("log", level="error", message=f"{summary}\n{tail}".strip()) + writer.emit("result", ok=False, summary=summary) + result = {"ok": False, "summary": summary, "state": "failed"} + _atomic_write( + path / RESULT_NAME, + json.dumps(result | {"exit_code": _EXIT_SPAWN_FAILED}, indent=2) + "\n", + ) + writer.emit("state", state="failed") + return JobError(f"{summary} {tail}".strip(), code="op_failed") + + +def _confirm_started(proc: subprocess.Popen[bytes], path: Path) -> None: + """Return once the job runs; raise if the launcher died before it did. + + A job that already finished (fast read-only commands can, without + systemd) wrote its own result, so an exit with a result is a success. + + Raises: + JobError: ``op_failed`` when the launcher exited without the job + ever running. + """ + deadline = time.monotonic() + _CONFIRM_SECONDS + while time.monotonic() < deadline: + code = proc.poll() + if code is not None: + if any(e.get("type") == "result" for e in read_events(path)): + return + msg = f"The job process could not be started (launcher exit {code})." + raise _fail_unstarted(path, msg) + if _is_job_interpreter(proc.pid): + return + time.sleep(_POLL_SECONDS) + + +def spawn_job(path: Path, lock_fd: int | None) -> int: + """Start the detached job process for *path* and return its pid. + + Args: + path: The prepared job directory. + lock_fd: The held one-mutating-job lock, handed to the child. + + Raises: + JobError: ``op_failed`` when the process could not be started; the + job is already marked ``failed`` with the reason in its log. + """ + env = dict(os.environ) + env["PYTHONPATH"] = os.pathsep.join( + p for p in (str(_PACKAGE_ROOT), env.get("PYTHONPATH", "")) if p + ) + if lock_fd is not None: + env[LOCK_FD_ENV] = str(lock_fd) + try: + with (path / OUTPUT_NAME).open("ab") as out: + proc = subprocess.Popen( + _spawn_argv(path), + stdin=subprocess.DEVNULL, + stdout=out, + stderr=out, + cwd=_PACKAGE_ROOT, + env=env, + start_new_session=True, + pass_fds=() if lock_fd is None else (lock_fd,), + ) + except OSError as exc: + msg = f"The job process could not be started: {exc}." + raise _fail_unstarted(path, msg) from exc + _confirm_started(proc, path) + return proc.pid diff --git a/steam_backlog_enforcer/jobs/_specs.py b/steam_backlog_enforcer/jobs/_specs.py new file mode 100644 index 0000000..9a1d7e3 --- /dev/null +++ b/steam_backlog_enforcer/jobs/_specs.py @@ -0,0 +1,73 @@ +"""How each job command behaves: lock, cancel and privilege flags. + +Pure data, deliberately free of command imports. The web server imports +this (through the job store and the catalog) from inside +``steam_backlog_enforcer.main`` → ``misc`` → ``_web_server``; importing the +command modules from here would close that loop into a circular import. +The handlers live in :mod:`._registry`, which only the job subprocess loads. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from collections.abc import Mapping + + +@dataclass(frozen=True) +class JobFlags: + """How one command runs as a job. + + Attributes: + mutating: Takes the one-mutating-job lock. + cancellable: SIGTERM cancels it (only where stopping midway is safe). + privileged: Runs in the root daemon; never spawned as a job. + """ + + mutating: bool = True + cancellable: bool = False + privileged: bool = False + + +_PRIVILEGED = JobFlags(privileged=True) + +JOB_FLAGS: Final[dict[str, JobFlags]] = { + # check is mutating: a new achievement releases the game and assigns + # the next one, exactly like done. + "check": JobFlags(), + "scan": JobFlags(cancellable=True), + "done": JobFlags(), + "pick": JobFlags(), + "pick-manual": JobFlags(), + "abandon-pick": JobFlags(), + "install": JobFlags(), + "uninstall": JobFlags(), + "hide": JobFlags(), + "unhide": JobFlags(), + "add-exception": JobFlags(), + "reset": JobFlags(), + "restore-backup": JobFlags(), + "stats": JobFlags(mutating=False, cancellable=True), + "list": JobFlags(mutating=False, cancellable=True), + # Only the demo runs as a job (see is_privileged). + "enforce": JobFlags(cancellable=True), + "gaming-reset": _PRIVILEGED, + "gaming-unblock": _PRIVILEGED, + "block-gaming": _PRIVILEGED, + "unblock": _PRIVILEGED, + "buy-dlc": _PRIVILEGED, +} + + +def is_privileged(command: str, params: Mapping[str, object]) -> bool: + """Whether *command* with *params* must go to the root daemon. + + ``enforce`` is both: its 60-second demo runs as a job, anything else is + the real enforcer, which only the daemon may (re)start. + """ + if command == "enforce": + return not params.get("demo") + flags = JOB_FLAGS.get(command) + return flags is not None and flags.privileged diff --git a/steam_backlog_enforcer/jobs/_store.py b/steam_backlog_enforcer/jobs/_store.py new file mode 100644 index 0000000..f2135f0 --- /dev/null +++ b/steam_backlog_enforcer/jobs/_store.py @@ -0,0 +1,199 @@ +"""Create, prune and start jobs; enforce one mutating job at a time. + +The ``busy`` check is a ``flock`` on ``jobs/.mutating.lock``. The server +takes it non-blocking *before* spawning (:mod:`._spawn`) and hands the open +descriptor to the child (``pass_fds``), so the child holds the lock for +exactly its own lifetime and there is no window between "nobody is running" +and "I am". The request is validated, lock gate included, *before* the lock +is taken, so a locked command is refused as ``locked``, never ``busy``. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +import fcntl +import json +import os +from pathlib import Path +import re +import secrets +import shutil +from typing import TYPE_CHECKING, Final + +from steam_backlog_enforcer._command_gate import lock_reason +from steam_backlog_enforcer._command_params import ( + InvalidParamsError, + validate_params, +) +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.jobs._errors import JobError, PrivilegedViaDaemonError +from steam_backlog_enforcer.jobs._events import ( + PID_NAME, + REQUEST_NAME, + TERMINAL_STATES, + EventWriter, + now_iso, + read_events, +) +from steam_backlog_enforcer.jobs._spawn import spawn_job +from steam_backlog_enforcer.jobs._specs import JOB_FLAGS, is_privileged + +if TYPE_CHECKING: + from collections.abc import Mapping + +JOBS_DIR = Path.home() / ".local" / "state" / "steam-backlog-enforcer" / "jobs" +MAX_JOBS: Final = 50 +LOCK_NAME: Final = ".mutating.lock" +# Microseconds since 2026-10: two jobs created in the same second still +# sort by creation. Ids without them (older history) remain valid. +_ID_PATTERN = re.compile(r"^\d{8}T\d{6}(?:\d{6})?Z-[0-9a-f]{6}$") + + +def job_dir(job_id: str) -> Path: + """Resolve a job id to its directory, refusing anything path-like. + + Raises: + JobError: ``not_found`` for a malformed or unknown id. + """ + path = JOBS_DIR / job_id + if not _ID_PATTERN.fullmatch(job_id) or not (path / REQUEST_NAME).is_file(): + msg = f"No such job: {job_id!r}" + raise JobError(msg, code="not_found") + return path + + +def new_job_id() -> str: + """A sortable id: UTC timestamp to the microsecond, plus a random suffix. + + Sorting by name is creation order (newest-first lists, oldest-first + pruning); the suffix only keeps simultaneous ids distinct. + """ + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ") + return f"{stamp}-{secrets.token_hex(3)}" + + +def is_terminal(path: Path) -> bool: + """Whether the job in *path* has reached a final state.""" + states = [e.get("state") for e in read_events(path) if e.get("type") == "state"] + return bool(states) and states[-1] in TERMINAL_STATES + + +def prune(keep: int = MAX_JOBS) -> None: + """Delete the oldest finished job dirs so at most *keep* remain. + + A job that is still running is never deleted, however old. + """ + if not JOBS_DIR.is_dir(): + return + dirs = sorted( + (p for p in JOBS_DIR.iterdir() if _ID_PATTERN.fullmatch(p.name)), + key=lambda p: p.name, + ) + for path in dirs[: max(0, len(dirs) - keep)]: + if is_terminal(path): + shutil.rmtree(path, ignore_errors=True) + + +def acquire_mutating_lock() -> int: + """Take the one-mutating-job lock and return its open descriptor. + + Raises: + JobError: ``busy`` when another mutating job holds it. + """ + JOBS_DIR.mkdir(parents=True, exist_ok=True) + fd = os.open(JOBS_DIR / LOCK_NAME, os.O_RDWR | os.O_CREAT, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + os.close(fd) + msg = "Another job that changes state is running." + raise JobError(msg, code="busy") from None + return fd + + +def _check_request(command: str, params: Mapping[str, object]) -> dict[str, object]: + """Validate a request the way the server must before creating a job. + + Raises: + JobError: ``unknown_command``, ``invalid_params`` or ``locked``. + PrivilegedViaDaemonError: The command belongs to the root daemon. + """ + if command not in JOB_FLAGS: + msg = f"{command!r} does not run as a job." + raise JobError(msg, code="unknown_command") + try: + clean = validate_params(command, params) + except InvalidParamsError as exc: + raise JobError(str(exc), code="invalid_params") from exc + if is_privileged(command, clean): + raise PrivilegedViaDaemonError(command) + reason = lock_reason(command, Config.load(), State.load()) + if reason is not None: + raise JobError(reason, code="locked") + return dict(clean) + + +def prepare_job( + command: str, + params: Mapping[str, object], + *, + confirm_phrase: str | None = None, +) -> Path: + """Validate a request and write its job dir, without starting it. + + Returns: + The new job directory, holding ``request.json`` and a ``queued`` event. + """ + return _write_job( + command, _check_request(command, params), confirm_phrase=confirm_phrase + ) + + +def _write_job( + command: str, clean: Mapping[str, object], *, confirm_phrase: str | None +) -> Path: + """Write the job dir for an already-validated request.""" + prune(MAX_JOBS - 1) + path = JOBS_DIR / new_job_id() + path.mkdir(parents=True, mode=0o700) + request: dict[str, object] = { + "command": command, + "params": dict(clean), + "created_at": now_iso(), + } + if confirm_phrase is not None: + request["confirm_phrase"] = confirm_phrase + (path / REQUEST_NAME).write_text(json.dumps(request) + "\n", encoding="utf-8") + EventWriter(path).emit("state", state="queued") + return path + + +def create_job( + command: str, + params: Mapping[str, object], + *, + confirm_phrase: str | None = None, +) -> str: + """Create and start a job; return its id. + + Args: + command: Canonical command name. + params: Raw params from ``JobRequest.params``. + confirm_phrase: ``JobRequest.confirm_phrase``; re-checked by the job. + + Raises: + JobError: ``unknown_command``, ``invalid_params``, ``locked``, + ``busy``, or ``op_failed`` when the process could not be started + (the job is then already marked ``failed``). + PrivilegedViaDaemonError: Route this request to the daemon instead. + """ + clean = _check_request(command, params) + lock_fd = acquire_mutating_lock() if JOB_FLAGS[command].mutating else None + try: + path = _write_job(command, clean, confirm_phrase=confirm_phrase) + pid = spawn_job(path, lock_fd) + (path / PID_NAME).write_text(f"{pid}\n", encoding="utf-8") + finally: + if lock_fd is not None: + os.close(lock_fd) # The child keeps its inherited copy. + return path.name diff --git a/steam_backlog_enforcer/jobs/_view.py b/steam_backlog_enforcer/jobs/_view.py new file mode 100644 index 0000000..a471cb3 --- /dev/null +++ b/steam_backlog_enforcer/jobs/_view.py @@ -0,0 +1,149 @@ +"""Read jobs back for the API: the ``Job`` objects and their event streams. + +A ``Job`` is never stored; it is folded from ``request.json`` and the event +log every time, so it cannot disagree with the events the UI streams. + +A job whose process died without writing a result (killed, OOM, machine +reboot) would otherwise read as "running" forever. :func:`read_job` notices +the dead pid and appends the ``failed`` verdict on the job's behalf. +""" + +from __future__ import annotations + +import json +from pathlib import Path +import time +from typing import Any, Final + +from steam_backlog_enforcer.jobs import _store +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._events import ( + PID_NAME, + REQUEST_NAME, + RESULT_NAME, + TERMINAL_STATES, + EventWriter, + read_events, +) + +# A fresh job has no pid file for the instant between mkdir and spawn. +_SPAWN_GRACE_SECONDS: Final = 30 +_PROGRESS_KEYS: Final = ("step", "total", "label", "item", "eta_seconds") + + +def _pid_alive(path: Path) -> bool | None: + """Whether the job's own process is running; ``None`` if unknown yet. + + Checks the command line too, so a recycled pid is not mistaken for the + job (a zombie has an empty one and counts as dead). + """ + try: + pid = int((path / PID_NAME).read_text(encoding="utf-8").strip()) + except FileNotFoundError, ValueError: + return None + try: + cmdline = Path(f"/proc/{pid}/cmdline").read_bytes() + except OSError: + return False + return b"steam_backlog_enforcer.jobs" in cmdline and path.name.encode() in cmdline + + +def pid_of(path: Path) -> int | None: + """The live job process's pid, or ``None`` if it is not running.""" + if not _pid_alive(path): + return None + return int((path / PID_NAME).read_text(encoding="utf-8").strip()) + + +def _reconcile(path: Path, state: str) -> None: + """Fail a non-terminal job whose process is gone.""" + if state in TERMINAL_STATES: + return + alive = _pid_alive(path) + if alive is None: + age = time.time() - (path / REQUEST_NAME).stat().st_mtime + if age < _SPAWN_GRACE_SECONDS: + return + elif alive: + return + summary = "The job process exited without reporting a result." + writer = EventWriter(path) + writer.emit("result", ok=False, summary=summary) + writer.emit("state", state="failed") + + +def _fold(path: Path) -> dict[str, Any]: + """Build the contract's ``Job`` object from one job dir.""" + request = json.loads((path / REQUEST_NAME).read_text(encoding="utf-8")) + job: dict[str, Any] = { + "id": path.name, + "command": request["command"], + "params": request.get("params", {}), + "state": "queued", + "created_at": request.get("created_at"), + "started_at": None, + "ended_at": None, + "exit_code": None, + "progress": None, + "summary": None, + } + for event in read_events(path): + kind = event.get("type") + if kind == "state": + job["state"] = event["state"] + if event["state"] == "running" and job["started_at"] is None: + job["started_at"] = event["ts"] + if event["state"] in TERMINAL_STATES: + job["ended_at"] = event["ts"] + elif kind == "progress": + job["progress"] = {k: event[k] for k in _PROGRESS_KEYS if k in event} + elif kind == "result": + job["summary"] = event.get("summary") + try: + result = json.loads((path / RESULT_NAME).read_text(encoding="utf-8")) + job["exit_code"] = result.get("exit_code") + except FileNotFoundError, ValueError: + pass + return job + + +def read_job(job_id: str) -> dict[str, Any]: + """Return one ``Job``, failing it first if its process died. + + Raises: + JobError: ``not_found`` for an unknown id. + """ + path = _store.job_dir(job_id) + job = _fold(path) + if job["state"] not in TERMINAL_STATES: + _reconcile(path, job["state"]) + job = _fold(path) + return job + + +def list_jobs() -> list[dict[str, Any]]: + """Return every kept ``Job``, newest first (at most ``MAX_JOBS``).""" + if not _store.JOBS_DIR.is_dir(): + return [] + ids = sorted( + (p.name for p in _store.JOBS_DIR.iterdir() if (p / REQUEST_NAME).is_file()), + reverse=True, + ) + jobs = [] + for job_id in ids[: _store.MAX_JOBS]: + try: + jobs.append(read_job(job_id)) + except JobError, OSError, ValueError, KeyError: + continue # A half-written or hand-damaged dir is not fatal. + return jobs + + +def read_job_events(job_id: str, after_seq: int = 0) -> list[dict[str, Any]]: + """Return a job's events after *after_seq*, for the SSE stream. + + Raises: + JobError: ``not_found`` for an unknown id. + """ + path = _store.job_dir(job_id) + read_job(job_id) # Reconcile first, so a dead job's stream can end. + return read_events(path, after_seq) diff --git a/steam_backlog_enforcer/library_hider.py b/steam_backlog_enforcer/library_hider.py index 7d9debc..83fba4d 100644 --- a/steam_backlog_enforcer/library_hider.py +++ b/steam_backlog_enforcer/library_hider.py @@ -24,6 +24,7 @@ from steam_backlog_enforcer._actions import allowed_app_ids from steam_backlog_enforcer._cdp import _cdp_result_value, _evaluate_js from steam_backlog_enforcer._echo import _echo from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids +from steam_backlog_enforcer._progress import current_progress from steam_backlog_enforcer._steam_errors import ( SteamUnavailableError, ) @@ -177,10 +178,15 @@ def try_hide_other_games( pass ran; otherwise it explains why hiding was skipped and the count is 0. """ + # One CDP evaluation does every pass, so there is no per-game step. + progress = current_progress() + progress.phase("Hiding games in the Steam library", 1) try: return hide_other_games(owned_app_ids, allowed_app_ids), None except SteamUnavailableError as exc: return 0, str(exc) + finally: + progress.advance(step=1) def unhide_all_games(owned_app_ids: list[int]) -> int: @@ -188,6 +194,9 @@ def unhide_all_games(owned_app_ids: list[int]) -> int: Returns the number of games that were unhidden. """ + # One CDP evaluation unhides everything, so the phase is a single step. + progress = current_progress() + progress.phase("Unhiding games in the Steam library", 1) ensure_steam_debug_port() json.dumps(sorted(owned_app_ids)) @@ -203,6 +212,7 @@ def unhide_all_games(owned_app_ids: list[int]) -> int: """ result = _evaluate_js(js) + progress.advance(step=1) value = _cdp_result_value(result) parsed = json.loads(value) count: int = parsed["count"] diff --git a/steam_backlog_enforcer/main/__init__.py b/steam_backlog_enforcer/main/__init__.py index 87f4d88..1083c06 100644 --- a/steam_backlog_enforcer/main/__init__.py +++ b/steam_backlog_enforcer/main/__init__.py @@ -23,9 +23,14 @@ from steam_backlog_enforcer._cmd_playtime import ( cmd_gaming_status, cmd_gaming_unblock, ) +from steam_backlog_enforcer._command_help import ( + COMMAND_DESCRIPTIONS, + EXTRA_COMMAND_DESCRIPTIONS, +) from steam_backlog_enforcer._stats import cmd_stats from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.game_install import _echo +from steam_backlog_enforcer.main._gate import enforce_gate from steam_backlog_enforcer.main._registry import _make_all_commands from steam_backlog_enforcer.main._registry import ( _print_usage as _print_usage_for, @@ -45,6 +50,7 @@ from steam_backlog_enforcer.main._shared import ( _show_total_block_lock_message, ) from steam_backlog_enforcer.main.abandon import cmd_abandon_pick +from steam_backlog_enforcer.main.exception import cmd_add_exception from steam_backlog_enforcer.main.install import ( cmd_hide, cmd_install, @@ -53,7 +59,6 @@ from steam_backlog_enforcer.main.install import ( cmd_uninstall, ) from steam_backlog_enforcer.main.misc import ( - cmd_add_exception, cmd_block_gaming, cmd_buy_dlc, cmd_reset, @@ -117,41 +122,34 @@ __all__ = [ "main", ] +_HANDLERS: dict[str, Callable[[Config, State], object]] = { + "scan": do_scan, + "check": do_check, + "status": cmd_status, + "list": cmd_list, + "install": cmd_install, + "hide": cmd_hide, + "unhide": cmd_unhide, + "buy-dlc": cmd_buy_dlc, + "reset": cmd_reset, + "installed": cmd_installed, + "uninstall": cmd_uninstall, + "setup": cmd_setup, + "done": cmd_done, + "pick": cmd_pick, + "stats": cmd_stats, + "gaming-status": cmd_gaming_status, + "gaming-reset": cmd_gaming_reset, +} + +# Descriptions live in _command_help (the web catalog reads them too); the +# help order is theirs. COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = { - "scan": ("Scan library & assign a game", do_scan), - "check": ("Check assigned game for a new achievement", do_check), - "status": ("Show current status", cmd_status), - "list": ("List games from snapshot", cmd_list), - "install": ("Install the assigned game", cmd_install), - "hide": ("Hide all non-assigned games in library", cmd_hide), - "unhide": ("Unhide all games in library", cmd_unhide), - "buy-dlc": ("Unblock the store for 15 min to buy a game/DLC", cmd_buy_dlc), - "reset": ("Reset all state", cmd_reset), - "installed": ("List installed games", cmd_installed), - "uninstall": ("Uninstall all non-assigned games", cmd_uninstall), - "setup": ("Run first-time setup", cmd_setup), - "done": ("Move on after a new achievement, pick next", cmd_done), - "pick": ("Manually pick your next game from candidates", cmd_pick), - "stats": ("Show backlog completion-time estimates", cmd_stats), - "gaming-status": ("Show today's gaming time and block state", cmd_gaming_status), - "gaming-reset": ("Reset today's gaming counter (root + YES)", cmd_gaming_reset), + name: (desc, _HANDLERS[name]) for name, desc in COMMAND_DESCRIPTIONS.items() } -# Extra commands with non-standard arg handling (shown in help but not in COMMANDS). -_EXTRA_COMMAND_DESCRIPTIONS: dict[str, str] = { - "add-exception": "Request 24h-locked whitelist exception (use --reason)", - "unblock": "Unblock the store for [minutes] (default 15, max 30)", - "serve": "Start the web UI (--port N; replaces a stale server)", - "pick-manual": f"Pick a game by app_id, lock enforcer for {_MANUAL_LOCK_DAYS} days", - "abandon-pick": "Undo a manual pick at any time (needs app_id)", - "block-gaming": "Block ALL gaming for days, no in-app undo", - "enforce": "Run enforcer: block, uninstall, kill, hide (--demo for a 60s budget)", - "gaming-unblock": "Force-release playtime bind mounts (root; recovery hatch)", -} - -_ALL_COMMANDS: dict[str, str] = _make_all_commands( - COMMANDS, _EXTRA_COMMAND_DESCRIPTIONS -) +# Plus the commands with non-standard arg handling (help only, not COMMANDS). +_ALL_COMMANDS: dict[str, str] = _make_all_commands(COMMANDS, EXTRA_COMMAND_DESCRIPTIONS) def _resolve_command(raw: str) -> str | None: @@ -215,19 +213,8 @@ def main() -> None: _echo(f"Note: treating '{sys.argv[1]}' as '{command}'.") config = Config.load() - - if command not in {"setup", "add-exception"} and not config.steam_api_key: - _echo("Not configured. Run 'setup' first.") - sys.exit(1) - - state = State.load() - - # Total block is the most restrictive lock - check it first. - _enforce_total_block_lock(command) - - # Enforce the manual-pick lock before dispatching any command. - # This also covers add-exception (previously dispatched before state load). - _enforce_manual_pick_lock(command, state) + # Not-configured, total-block and manual-pick checks; web jobs share it. + state = enforce_gate(command, config) if _dispatch_extra_command(command, config, state): return diff --git a/steam_backlog_enforcer/main/_gate.py b/steam_backlog_enforcer/main/_gate.py new file mode 100644 index 0000000..d390b09 --- /dev/null +++ b/steam_backlog_enforcer/main/_gate.py @@ -0,0 +1,51 @@ +"""The lock gate every command passes before it runs, CLI or web job. + +One gate, so a command refused at the terminal is refused in the browser +too. The rules themselves live in :mod:`steam_backlog_enforcer._command_gate` +(:func:`~steam_backlog_enforcer._command_gate.lock_reason`); this module is +the CLI's printing, exiting half of it. +""" + +from __future__ import annotations + +import sys +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._command_gate import NOT_CONFIGURED_MSG, UNCONFIGURED_OK +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.game_install import _echo +from steam_backlog_enforcer.main._shared import ( + _enforce_manual_pick_lock, + _enforce_total_block_lock, +) + +if TYPE_CHECKING: + from steam_backlog_enforcer.config import Config + + +def enforce_gate(command: str, config: Config) -> State: + """Run the gate the CLI way: print the lock message and exit on refusal. + + The web job runner calls this too, under its output sink, so the very + same lock banner reaches the UI as log lines. + + Args: + command: Canonical command name (never raw argv). + config: Loaded configuration. + + Returns: + The loaded state, once every check passed. + """ + if command not in UNCONFIGURED_OK and not config.steam_api_key: + _echo(NOT_CONFIGURED_MSG) + sys.exit(1) + + state = State.load() + + # Total block is the most restrictive lock - check it first. + _enforce_total_block_lock(command) + + # Enforce the manual-pick lock before dispatching any command. + # This also covers add-exception (previously dispatched before state load). + _enforce_manual_pick_lock(command, state) + return state diff --git a/steam_backlog_enforcer/main/_shared.py b/steam_backlog_enforcer/main/_shared.py index 9a107dc..9536893 100644 --- a/steam_backlog_enforcer/main/_shared.py +++ b/steam_backlog_enforcer/main/_shared.py @@ -18,6 +18,10 @@ from steam_backlog_enforcer._actions import ( from steam_backlog_enforcer._allowed_games import ( MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS, ) +from steam_backlog_enforcer._command_locks import ( + _MANUAL_LOCK_EXEMPT_COMMANDS, + _TOTAL_BLOCK_EXEMPT_COMMANDS, +) from steam_backlog_enforcer._manual_pick_lifecycle import manual_pick_age_days from steam_backlog_enforcer._total_block import ( TotalBlockStatus, @@ -32,42 +36,6 @@ if TYPE_CHECKING: _LIST_DISPLAY_LIMIT = 50 _MIN_CLI_ARGS = 2 -# Commands that remain usable while the manual pick lock is active. -# Principle: only what is needed to release the lock (done/check) or -# that cannot change the game assignment (status, enforce, setup, serve). -_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset( - { - "done", - "check", - "status", - "enforce", - "setup", - "serve", - "abandon-pick", - # Allowed so a second game can be locked in alongside the first; the - # cap inside cmd_pick_manual is what stops this being a way out. - "pick-manual", - # The daily gaming budget is orthogonal to which game is assigned, and - # gaming-unblock is a recovery hatch for a stuck bind mount - locking - # it behind a manual pick would leave Steam masked with no way back. - "gaming-status", - "gaming-unblock", - "gaming-reset", - } -) - -# Commands that remain usable while a total gaming block is active. Far -# stricter than _MANUAL_LOCK_EXEMPT_COMMANDS: no done/pick/reset/ -# add-exception - there is no in-app way to shorten a total block. -# -# gaming-unblock is included because a playtime bind mount makes the total -# block's own `pacman -R steam` fail EBUSY - it must stay reachable exactly -# when the two collide. gaming-reset is NOT included: it shortens enforcement. -_TOTAL_BLOCK_EXEMPT_COMMANDS = frozenset( - {"status", "enforce", "gaming-status", "gaming-unblock"} -) - - # ────────────────────────────────────────────────────────────── # Total gaming block lock helpers # ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/main/abandon.py b/steam_backlog_enforcer/main/abandon.py index cdec59d..79c329d 100644 --- a/steam_backlog_enforcer/main/abandon.py +++ b/steam_backlog_enforcer/main/abandon.py @@ -13,6 +13,7 @@ from steam_backlog_enforcer._actions import ( find_manual_pick, ) from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick +from steam_backlog_enforcer._prompter import confirm_phrase from steam_backlog_enforcer.game_install import ( _echo, is_game_installed, @@ -98,8 +99,9 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None: else: _echo("\n - Leaves you with no assigned game (run 'scan' to get one)") _echo() - confirm = input(f"Type YES to abandon {game_name}: ").strip() - if confirm != "YES": + if not confirm_phrase( + "abandon-pick", f"Abandon manual pick {game_name}?", game_name=game_name + ): _echo("Aborted.") return diff --git a/steam_backlog_enforcer/main/exception.py b/steam_backlog_enforcer/main/exception.py new file mode 100644 index 0000000..3fce247 --- /dev/null +++ b/steam_backlog_enforcer/main/exception.py @@ -0,0 +1,79 @@ +"""``add-exception``: request a whitelist exception for one game. + +Split out of :mod:`steam_backlog_enforcer.main.misc` when the command gained +its typed-phrase confirmation, to keep both files under the 250-line cap. +""" + +from __future__ import annotations + +import sys + +from steam_backlog_enforcer._prompter import confirm_phrase +from steam_backlog_enforcer._snapshot import snapshot_game_name +from steam_backlog_enforcer._whitelist import validate_reason +from steam_backlog_enforcer._whitelist_locking import add_pending_exception +from steam_backlog_enforcer.game_install import _echo + +_MIN_ADD_EXCEPTION_ARGS = 3 +_ADD_EXCEPTION_USAGE = ( + 'Usage: add-exception --reason ""\n' + " app_id : numeric Steam application ID\n" + " --reason : genuine justification (>= 5 words)\n\n" + "Example:\n" + " add-exception 440 --reason " + '"TF2 is needed for a community event this weekend"\n\n' + "Exceptions become active immediately." +) + + +def cmd_add_exception(args: list[str]) -> None: + """Add a whitelist exception, active immediately. + + Usage: add-exception --reason "" + + The exception becomes active right away (no cooldown). The reason must be + a genuine justification of at least 5 words with sufficient entropy, and + the ``add-exception`` friction phrase must be typed to confirm. + + Args: + args: CLI argument list after the command name. + """ + if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args: + _echo(_ADD_EXCEPTION_USAGE) + sys.exit(1) + + try: + app_id = int(args[0]) + except ValueError: + _echo(f"Error: app_id must be a number, got '{args[0]}'.") + sys.exit(1) + + reason_idx = args.index("--reason") + reason_parts = args[reason_idx + 1 :] + if not reason_parts: + _echo("Error: --reason requires a value.") + sys.exit(1) + reason = " ".join(reason_parts) + + # Show validation feedback before attempting to add. + err = validate_reason(reason) + if err is not None: + _echo(f"Invalid reason: {err}") + sys.exit(1) + + game_name = snapshot_game_name(app_id) or f"AppID={app_id}" + if not confirm_phrase( + "add-exception", + f"Request a whitelist exception for {game_name}?", + game_name=game_name, + ): + _echo("Aborted.") + sys.exit(1) + + try: + msg = add_pending_exception(app_id, reason) + except ValueError as exc: + _echo(f"Error: {exc}") + sys.exit(1) + + _echo(msg) diff --git a/steam_backlog_enforcer/main/install.py b/steam_backlog_enforcer/main/install.py index 7fb3ab2..5d04cb4 100644 --- a/steam_backlog_enforcer/main/install.py +++ b/steam_backlog_enforcer/main/install.py @@ -6,6 +6,8 @@ from typing import TYPE_CHECKING from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids +from steam_backlog_enforcer._progress import current_progress +from steam_backlog_enforcer._prompter import confirm_phrase from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.game_install import ( _echo, @@ -60,8 +62,9 @@ def cmd_uninstall(_config: Config, state: State) -> None: _echo(f" - {name} (AppID={aid})") _echo() - confirm = input("Type YES to confirm: ").strip() - if confirm != "YES": + if not confirm_phrase( + "uninstall", f"Uninstall {len(to_remove)} games?", count=len(to_remove) + ): _echo("Aborted.") return @@ -80,6 +83,9 @@ def cmd_install(config: Config, state: State) -> None: return _echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...") + # install_game only *starts* the download (fast installer or steam://); + # there is no download-progress loop to report on yet. + current_progress().phase(f"Starting install of {state.current_game_name}") if install_game( state.current_app_id, state.current_game_name, diff --git a/steam_backlog_enforcer/main/misc.py b/steam_backlog_enforcer/main/misc.py index bb8ae18..b62c355 100644 --- a/steam_backlog_enforcer/main/misc.py +++ b/steam_backlog_enforcer/main/misc.py @@ -1,7 +1,7 @@ """Commands that do not belong to a larger group. -Store blocking, state reset, setup, whitelist exceptions, the web UI and the -total gaming block. +Store blocking, state reset, setup, the web UI and the total gaming block. +``add-exception`` lives in :mod:`steam_backlog_enforcer.main.exception`. """ from __future__ import annotations @@ -10,8 +10,10 @@ import errno import sys from typing import TYPE_CHECKING +from steam_backlog_enforcer._backups import create_backup from steam_backlog_enforcer._config_setup import interactive_setup from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids +from steam_backlog_enforcer._prompter import confirm_phrase from steam_backlog_enforcer._serve_startup import ( ensure_port_available, parse_serve_args, @@ -24,26 +26,12 @@ from steam_backlog_enforcer._store_window import ( from steam_backlog_enforcer._total_block import start_total_block from steam_backlog_enforcer._web_build import build_frontend, frontend_is_stale from steam_backlog_enforcer._web_server import serve -from steam_backlog_enforcer._whitelist import validate_reason -from steam_backlog_enforcer._whitelist_locking import add_pending_exception from steam_backlog_enforcer.game_install import _echo from steam_backlog_enforcer.library_hider import unhide_all_games -from steam_backlog_enforcer.store_blocker import unblock_store if TYPE_CHECKING: from steam_backlog_enforcer.config import Config, State -_MIN_ADD_EXCEPTION_ARGS = 3 -_ADD_EXCEPTION_USAGE = ( - 'Usage: add-exception --reason ""\n' - " app_id : numeric Steam application ID\n" - " --reason : genuine justification (>= 5 words)\n\n" - "Example:\n" - " add-exception 440 --reason " - '"TF2 is needed for a community event this weekend"\n\n' - "Exceptions become active immediately." -) - _BLOCK_GAMING_USAGE = ( "Usage: block-gaming \n" " days : whole number of days to block ALL gaming:\n" @@ -84,8 +72,22 @@ def cmd_buy_dlc(config: Config, state: State) -> None: def cmd_reset(config: Config, state: State) -> None: - """Reset all state (unblock, unhide, clear assignment).""" - unblock_store() + """Reset all state (unhide, clear assignment), backed up first. + + The store block is deliberately left alone. The daemon only re-blocks the + store at startup or when a timed window ends, so lifting it here would be + an open-ended window that skips ``unblock``'s 1-30 minute cap and phrase. + + Args: + config: Loaded configuration. + state: State to wipe. + """ + if not confirm_phrase("reset", "Wipe all enforcer state?"): + _echo("Aborted.") + return + backup = create_backup("before reset") + if backup is not None: + _echo(f"State backed up as {backup.id}.") # Unhide all games in the library. try: @@ -108,7 +110,7 @@ def cmd_reset(config: Config, state: State) -> None: state.last_assigned_at = {} state.released_at = {} state.save() - _echo("State reset. Store unblocked.") + _echo("State reset. Store left blocked: open a timed window with 'unblock'.") def cmd_setup(_config: Config, _state: State) -> None: @@ -144,49 +146,6 @@ def cmd_serve(args: list[str]) -> None: sys.exit(1) -def cmd_add_exception(args: list[str]) -> None: - """Add a whitelist exception, active immediately. - - Usage: add-exception --reason "" - - The exception becomes active right away (no cooldown). The reason must be - a genuine justification of at least 5 words with sufficient entropy. - - Args: - args: CLI argument list after the command name. - """ - if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args: - _echo(_ADD_EXCEPTION_USAGE) - sys.exit(1) - - try: - app_id = int(args[0]) - except ValueError: - _echo(f"Error: app_id must be a number, got '{args[0]}'.") - sys.exit(1) - - reason_idx = args.index("--reason") - reason_parts = args[reason_idx + 1 :] - if not reason_parts: - _echo("Error: --reason requires a value.") - sys.exit(1) - reason = " ".join(reason_parts) - - # Show validation feedback before attempting to add. - err = validate_reason(reason) - if err is not None: - _echo(f"Invalid reason: {err}") - sys.exit(1) - - try: - msg = add_pending_exception(app_id, reason) - except ValueError as exc: - _echo(f"Error: {exc}") - sys.exit(1) - - _echo(msg) - - def cmd_block_gaming(args: list[str]) -> None: """Start a total gaming block for a fixed number of days. @@ -220,8 +179,9 @@ def cmd_block_gaming(args: list[str]) -> None: f"\nsystem administration outside this tool." ) _echo() - confirm = input(f"Type YES to confirm a {days}-day total gaming block: ").strip() - if confirm != "YES": + if not confirm_phrase( + "block-gaming", f"Block all gaming for {days} day(s)?", days=days + ): _echo("Aborted.") return diff --git a/steam_backlog_enforcer/main/picks.py b/steam_backlog_enforcer/main/picks.py index 10387a2..ecb8e20 100644 --- a/steam_backlog_enforcer/main/picks.py +++ b/steam_backlog_enforcer/main/picks.py @@ -20,6 +20,8 @@ from steam_backlog_enforcer._pick_completion import ( report_completion, warn_stale_assignment, ) +from steam_backlog_enforcer._progress import tracked +from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.game_install import ( @@ -129,7 +131,8 @@ def _apply_allowed_set(config: Config, state: State) -> None: if count: _echo(f" Uninstalled {count} non-allowed game(s)") - for app_id, name in allowed_games(state): + games = allowed_games(state) + for app_id, name in tracked(games, "Installing allowed games", lambda g: g[1]): if is_game_fully_installed(app_id): _echo(f" {name} is already installed.") continue @@ -149,7 +152,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None: state: Current enforcer state. args: Remaining CLI args (first element should be the app_id). """ - raw_id = args[0] if args else input("Enter Steam app_id: ").strip() + raw_id = args[0] if args else current_prompter().text("Enter Steam app_id") try: app_id = int(raw_id) @@ -189,11 +192,11 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None: " playable until you confirm)" ) _echo() - confirm = input( - f"Type YES to confirm you will play {game_name} until you earn a new" - " achievement: " - ).strip() - if confirm != "YES": + if not confirm_phrase( + "pick-manual", + f"Confirm you will play {game_name} until you earn a new achievement.", + game_name=game_name, + ): _echo("Aborted.") warn_stale_assignment(state, retired) return diff --git a/steam_backlog_enforcer/protondb.py b/steam_backlog_enforcer/protondb.py index a8ec318..528a7c7 100644 --- a/steam_backlog_enforcer/protondb.py +++ b/steam_backlog_enforcer/protondb.py @@ -17,6 +17,7 @@ from typing import Any import aiohttp +from steam_backlog_enforcer._progress import gather_tracked from steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write logger = logging.getLogger(__name__) @@ -155,7 +156,7 @@ async def _fetch_batch(app_ids: list[int]) -> list[ProtonDBRating]: sem = asyncio.Semaphore(MAX_CONCURRENT) async with aiohttp.ClientSession() as session: tasks = [_fetch_one(session, sem, aid) for aid in app_ids] - results = await asyncio.gather(*tasks) + results = await gather_tracked("Fetching ProtonDB ratings", tasks) return [r for r in results if r is not None] diff --git a/steam_backlog_enforcer/scanning.py b/steam_backlog_enforcer/scanning.py index ab71579..dc63775 100644 --- a/steam_backlog_enforcer/scanning.py +++ b/steam_backlog_enforcer/scanning.py @@ -16,6 +16,7 @@ from steam_backlog_enforcer._hltb_types import ( load_hltb_polls_cache, ) from steam_backlog_enforcer._pick_completion import report_completion +from steam_backlog_enforcer._progress import current_progress from steam_backlog_enforcer._scanning_assign import ( _NO_CONF_MSG, _assign_chosen_game, @@ -151,7 +152,7 @@ def pick_next_game( state: State, config: Config, *, - on_select: Callable[[GameInfo], bool] | None = None, + on_select: Callable[[GameInfo], bool | GameInfo] | None = None, ) -> None: """Present a ranked list of eligible games and let the user pick one. @@ -181,8 +182,8 @@ def pick_next_game( _clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped)) return - idx = _prompt_user_pick(qualified) - _assign_chosen_game(qualified[idx], games, state, config) + chosen = _prompt_user_pick(qualified, games, state) + _assign_chosen_game(chosen, games, state, config) # ────────────────────────────────────────────────────────────── @@ -200,7 +201,10 @@ def do_check(config: Config, state: State) -> None: client = SteamAPIClient(config.steam_api_key, config.steam_id) _echo(f"Checking {state.current_game_name} (AppID={state.current_app_id})...") + progress = current_progress() + progress.phase("Fetching achievements from Steam", 1) game = client.refresh_single_game(state.current_app_id, state.current_game_name) + progress.advance(state.current_game_name) if game is None: _echo(" Could not fetch achievement data.") return diff --git a/steam_backlog_enforcer/tests/_ctl_fixtures.py b/steam_backlog_enforcer/tests/_ctl_fixtures.py new file mode 100644 index 0000000..eeb0a70 --- /dev/null +++ b/steam_backlog_enforcer/tests/_ctl_fixtures.py @@ -0,0 +1,64 @@ +"""Shared builders for the control-socket tests.""" + +from __future__ import annotations + +from contextlib import contextmanager, suppress +from datetime import UTC, datetime +from pathlib import Path +import socket +import tempfile +import threading +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._ctl_context import CtlContext +from steam_backlog_enforcer.config import Config + +if TYPE_CHECKING: + from collections.abc import Iterator + + +def make_ctx(*, supervised: bool = True) -> CtlContext: + """A fresh op context over a default config.""" + return CtlContext( + config=Config(), + tick_lock=threading.Lock(), + restart_event=threading.Event(), + started_at=datetime(2026, 1, 1, tzinfo=UTC), + supervised=supervised, + ) + + +@contextmanager +def short_socket_dir() -> Iterator[Path]: + """A short-path temp dir: AF_UNIX paths are capped near 108 bytes.""" + with tempfile.TemporaryDirectory(prefix="ctl") as name: + yield Path(name) + + +def serve_once(path: Path, reply: bytes) -> threading.Thread: + """Answer exactly one connection on *path* with *reply*, then close. + + The listener is bound before this returns, so a client may connect at once. + """ + listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + listener.bind(str(path)) + listener.listen(1) + + def run() -> None: + conn, _ = listener.accept() + with conn, listener: + conn.settimeout(5) + data = b"" + while b"\n" not in data: + chunk = conn.recv(1024) + if not chunk: + break + data += chunk + # The client may hang up mid-reply (the oversize case). + if reply: + with suppress(OSError): + conn.sendall(reply) + + thread = threading.Thread(target=run, daemon=True) + thread.start() + return thread diff --git a/steam_backlog_enforcer/tests/_fake_prompter.py b/steam_backlog_enforcer/tests/_fake_prompter.py new file mode 100644 index 0000000..c89109b --- /dev/null +++ b/steam_backlog_enforcer/tests/_fake_prompter.py @@ -0,0 +1,78 @@ +"""A scripted prompter for tests that drive the questions a command asks. + +Answers are consumed in order; asking more questions than were scripted is a +test bug and raises ``IndexError``. :class:`FakePicker` adds the optional +``game`` question a browser front end can answer (``GamePicker``). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Sequence + + from steam_backlog_enforcer._prompter import PromptOption + + +class FakePrompter: + """Answers ``choice`` / ``confirm`` / ``phrase`` / ``text`` from scripts.""" + + def __init__( + self, + *, + interactive: bool = True, + choices: Sequence[str] = (), + confirms: Sequence[bool] = (), + phrases: Sequence[bool] = (), + texts: Sequence[str] = (), + ) -> None: + """Store the scripted answers and an empty question log.""" + self.interactive = interactive + self._choices = list(choices) + self._confirms = list(confirms) + self._phrases = list(phrases) + self._texts = list(texts) + self.asked: list[tuple[str, object]] = [] + + def choice(self, message: str, options: Sequence[PromptOption]) -> str: + """Log the offered values and return the next scripted choice.""" + self.asked.append((message, [option.value for option in options])) + return self._choices.pop(0) + + def confirm(self, message: str, *, default: bool) -> bool: + """Log the question and return the next scripted yes/no.""" + self.asked.append((message, default)) + return self._confirms.pop(0) + + def phrase(self, message: str, expected: str) -> bool: + """Log the expected phrase and return the next scripted verdict.""" + self.asked.append((message, expected)) + return self._phrases.pop(0) + + def text(self, message: str) -> str: + """Log the question and return the next scripted line.""" + self.asked.append((message, None)) + return self._texts.pop(0) + + +class FakePicker(FakePrompter): + """A prompter that can also show the library (a ``GamePicker``).""" + + def __init__( + self, + *, + picks: Sequence[int | None] = (), + choices: Sequence[str] = (), + texts: Sequence[str] = (), + ) -> None: + """Store the scripted library picks next to the other answers.""" + super().__init__(choices=choices, texts=texts) + self._picks = list(picks) + self.offered: list[list[int]] = [] + + def game(self, message: str, app_ids: Sequence[int]) -> int | None: + """Log the offered app ids and return the next scripted pick.""" + self.asked.append((message, None)) + self.offered.append(list(app_ids)) + return self._picks.pop(0) diff --git a/steam_backlog_enforcer/tests/_isolate_control_plane.py b/steam_backlog_enforcer/tests/_isolate_control_plane.py new file mode 100644 index 0000000..dee4823 --- /dev/null +++ b/steam_backlog_enforcer/tests/_isolate_control_plane.py @@ -0,0 +1,89 @@ +"""Autouse isolation of the control plane and the HLTB endpoint memo. + +Redirects the web cover-art caches, the daemon control socket, restart +markers, backups and web job store (all under ``/var/lib``, ``/run`` or the +real ``~/.local/state``) into ``tmp_path``, and forgets the remembered HLTB +search URL around every test. + +Split out of ``conftest.py`` to keep every file inside the 250-line cap; +``conftest`` imports the fixtures by name, which is what registers them. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer._hltb_search_api import forget_hltb_search_url + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + +@pytest.fixture(autouse=True) +def _isolate_control_plane( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[None]: + """Redirect the daemon control socket, restart state, backups and job dirs. + + Without it a test that reaches the restart-exit marker or a job store + touches ``/var/lib/steam-backlog-enforcer`` or the real + ``~/.local/state`` (and the web token lives under ``XDG_RUNTIME_DIR``). + Default arguments bound at import (``path=SOCKET_PATH``) cannot be + redirected here: tests pass an explicit path for those. + """ + state = tmp_path / "ctl_state" + monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path / "xdg_runtime")) + with ( + patch("steam_backlog_enforcer._ctl_gap.RESTART_EXIT_FILE", state / "exit.json"), + patch( + "steam_backlog_enforcer._ctl_restart.RESTART_STATE_FILE", + state / "restart.json", + ), + patch("steam_backlog_enforcer._ctl_protocol.SOCKET_PATH", state / "ctl.sock"), + patch("steam_backlog_enforcer._ctl_client.SOCKET_PATH", state / "ctl.sock"), + patch("steam_backlog_enforcer._ctl_server.SOCKET_PATH", state / "ctl.sock"), + patch("steam_backlog_enforcer._backups.BACKUPS_DIR", tmp_path / "backups"), + patch("steam_backlog_enforcer.jobs._store.JOBS_DIR", tmp_path / "jobs"), + ): + yield + + +@pytest.fixture(autouse=True) +def _isolate_cover_art(tmp_path: Path) -> Iterator[None]: + """Cover art: Steam's librarycache is read, the art cache is written.""" + with ( + patch( + "steam_backlog_enforcer._web_art.LIBRARYCACHE", + tmp_path / "fake_home" / "librarycache", + ), + patch("steam_backlog_enforcer._web_art.ART_CACHE_DIR", tmp_path / "art"), + ): + yield + + +@pytest.fixture(autouse=True) +def _forget_hltb_search_url() -> Iterator[None]: + """Start every test without a remembered HLTB search endpoint. + + ``_get_hltb_search_url`` memoises a discovered URL for half an hour; a + value left behind by one test would hide the discovery path in the next. + """ + forget_hltb_search_url() + yield + forget_hltb_search_url() + + +@pytest.fixture(autouse=True) +def _forget_hltb_search_url() -> Iterator[None]: + """Start every test without a remembered HLTB search endpoint. + + ``_get_hltb_search_url`` memoises a discovered URL for half an hour; a + value left behind by one test would hide the discovery path in the next. + """ + forget_hltb_search_url() + yield + forget_hltb_search_url() diff --git a/steam_backlog_enforcer/tests/_jobs_helpers.py b/steam_backlog_enforcer/tests/_jobs_helpers.py new file mode 100644 index 0000000..ab57bd6 --- /dev/null +++ b/steam_backlog_enforcer/tests/_jobs_helpers.py @@ -0,0 +1,53 @@ +"""Shared builders for the ``test_jobs_*`` files: fake job dirs on disk. + +The autouse ``_isolate_control_plane`` fixture points ``_store.JOBS_DIR`` at +``tmp_path``; everything here reaches it through the module attribute. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from steam_backlog_enforcer.jobs import _store +from steam_backlog_enforcer.jobs._events import ( + PID_NAME, + REQUEST_NAME, + RESULT_NAME, + EventWriter, +) + +if TYPE_CHECKING: + from pathlib import Path + +JOB_ID = "20261010T120000000000Z-abcdef" + + +def make_job( + command: str = "scan", + *, + job_id: str = JOB_ID, + states: tuple[str, ...] = ("queued", "running"), + pid: str | None = None, + params: dict[str, object] | None = None, +) -> Path: + """Write a job dir with a request and one ``state`` event per state.""" + path = _store.JOBS_DIR / job_id + path.mkdir(parents=True) + request = { + "command": command, + "params": params or {}, + "created_at": "2026-10-10T12:00:00Z", + } + (path / REQUEST_NAME).write_text(json.dumps(request) + "\n", encoding="utf-8") + writer = EventWriter(path) + for state in states: + writer.emit("state", state=state) + if pid is not None: + (path / PID_NAME).write_text(pid, encoding="utf-8") + return path + + +def write_result(path: Path, **fields: object) -> None: + """Write ``result.json`` the way a finished job does.""" + (path / RESULT_NAME).write_text(json.dumps(fields), encoding="utf-8") diff --git a/steam_backlog_enforcer/tests/_web_request.py b/steam_backlog_enforcer/tests/_web_request.py new file mode 100644 index 0000000..3bf2be1 --- /dev/null +++ b/steam_backlog_enforcer/tests/_web_request.py @@ -0,0 +1,28 @@ +"""Helpers shared by the ``test_web_*`` files: build a ``Request``.""" + +from __future__ import annotations + +from email.message import Message +from typing import Any + +from steam_backlog_enforcer._web_io import Reply, Request + + +def make_request( + *args: str, + body: dict[str, Any] | None = None, + query: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + method: str = "GET", +) -> Request: + """A ``Request`` with the captured path *args* and optional extras.""" + message = Message() + for name, value in (headers or {}).items(): + message[name] = value + return Request(method, "/", query or {}, args, message, body or {}) + + +def payload_of(reply: Reply) -> dict[str, Any]: + """The JSON-object payload of *reply* (fails the test if it is not one).""" + assert isinstance(reply.payload, dict) + return reply.payload diff --git a/steam_backlog_enforcer/tests/conftest.py b/steam_backlog_enforcer/tests/conftest.py index 00a9e41..9879587 100644 --- a/steam_backlog_enforcer/tests/conftest.py +++ b/steam_backlog_enforcer/tests/conftest.py @@ -9,8 +9,7 @@ to temporary directories. This stops tests from accidentally: - Modifying /etc/hosts via the store blocker - Corrupting the HLTB cache on disk - Launching real Steam or calling real subprocess commands - - Deleting real ~/.steam, ~/.local/share/Steam, etc. via the total-block - Steam/Proton remnant purge + - Deleting real ~/.steam, ~/.local/share/Steam via the remnant purge """ from __future__ import annotations @@ -20,7 +19,12 @@ from unittest.mock import MagicMock, patch import pytest -# Imported for its autouse side effect: naming it here registers it. +# Imported for the autouse side effect (naming them registers them). +from steam_backlog_enforcer.tests._isolate_control_plane import ( + _forget_hltb_search_url, + _isolate_control_plane, + _isolate_cover_art, +) from steam_backlog_enforcer.tests._isolate_incidents import _isolate_incidents from steam_backlog_enforcer.tests._isolate_playtime import _isolate_playtime from steam_backlog_enforcer.tests._isolate_reading import _isolate_reading @@ -33,6 +37,9 @@ from steam_backlog_enforcer.tests._no_workout_http import _no_workout_http # registers autouse fixtures by name, so they look unused to the linter. __all__ = [ "_block_real_subprocesses", + "_forget_hltb_search_url", + "_isolate_control_plane", + "_isolate_cover_art", "_isolate_incidents", "_isolate_playtime", "_isolate_reading", @@ -185,16 +192,15 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: def _no_real_sleep() -> Iterator[None]: """No-op every ``time.sleep`` used by the package. - Several modules call ``time.sleep`` for Steam-launch / install-retry / - rate-limit pacing. Individual tests that need to observe sleep - behaviour can override these patches inside their own ``with`` block. + Modules call ``time.sleep`` to pace Steam launch / install retries / + rate limits; tests that observe sleep override these patches. """ noop = MagicMock() with ( patch("steam_backlog_enforcer._steam_client.time.sleep", noop), patch("steam_backlog_enforcer._steam_launch.time.sleep", noop), patch("steam_backlog_enforcer._steam_api_client.time.sleep", noop), - # The loop waits on an inotify wake, not time.sleep: stub its factory. + # The loop waits on an inotify wake, not time.sleep. patch("steam_backlog_enforcer._enforce_loop.open_file_wake", noop), ): yield diff --git a/steam_backlog_enforcer/tests/test_achievement_skip.py b/steam_backlog_enforcer/tests/test_achievement_skip.py new file mode 100644 index 0000000..6f5f261 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_achievement_skip.py @@ -0,0 +1,133 @@ +"""Tests for _achievement_skip: remembering games with no achievements.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _achievement_skip as skip +from steam_backlog_enforcer import config + +_PKG = "steam_backlog_enforcer._achievement_skip" +_NOW = 1_000_000_000.0 +_STEAM_ID = "sid" + + +def _store(payload: object) -> None: + skip._cache_file().write_text(json.dumps(payload), encoding="utf-8") + + +def _game(app_id: int, *, stats: bool = False) -> dict[str, Any]: + return {"appid": app_id, "has_community_visible_stats": stats} + + +class _HttpError(Exception): + """What ``requests`` raises: an error that carries its response.""" + + def __init__(self, response: MagicMock) -> None: + super().__init__("http error") + self.response = response + + +def _failure(status: int | None, body: object = None) -> Exception: + """An exception whose cause carries an HTTP response (or none).""" + exc = RuntimeError("achievements failed") + if status is not None: + response = MagicMock(status_code=status) + if isinstance(body, Exception): + response.json.side_effect = body + else: + response.json.return_value = body + exc.__cause__ = _HttpError(response) + return exc + + +class TestCacheFile: + def test_lives_under_the_config_dir(self) -> None: + assert skip._cache_file() == config.CONFIG_DIR / "no_achievements_cache.json" + + +class TestLoadChecked: + def test_missing_file_is_empty(self) -> None: + assert skip._load_checked(_STEAM_ID) == {} + + def test_corrupt_file_is_empty(self) -> None: + skip._cache_file().write_text("{nope", encoding="utf-8") + assert skip._load_checked(_STEAM_ID) == {} + + @pytest.mark.parametrize("payload", [["list"], {"steam_id": "other"}]) + def test_other_account_or_shape_is_empty(self, payload: object) -> None: + _store(payload) + assert skip._load_checked(_STEAM_ID) == {} + + def test_skips_unparsable_entries(self) -> None: + _store({"steam_id": _STEAM_ID, "checked": {"1": 5, "x": 6, "2": None}}) + assert skip._load_checked(_STEAM_ID) == {1: 5.0} + + +class TestIsNoStatsAnswer: + def test_steams_no_stats_answer(self) -> None: + body = {"playerstats": {"error": "no stats", "success": False}} + assert skip.is_no_stats_answer(_failure(400, body)) is True + + def test_no_response_is_not_an_answer(self) -> None: + assert skip.is_no_stats_answer(_failure(None)) is False + + def test_other_status_is_not_an_answer(self) -> None: + assert skip.is_no_stats_answer(_failure(500, {})) is False + + def test_unparsable_body_is_not_an_answer(self) -> None: + assert skip.is_no_stats_answer(_failure(400, ValueError("html"))) is False + + def test_body_without_failure_flag_is_not_an_answer(self) -> None: + assert skip.is_no_stats_answer(_failure(400, {"playerstats": {}})) is False + + def test_non_dict_playerstats_is_not_an_answer(self) -> None: + assert skip.is_no_stats_answer(_failure(400, {"playerstats": 3})) is False + + +class TestSkippableAppIds: + def test_nothing_recorded_skips_nothing(self) -> None: + assert skip.skippable_app_ids(_STEAM_ID, [_game(1)], _NOW) == set() + + def test_recent_flagless_game_is_skipped(self) -> None: + _store({"steam_id": _STEAM_ID, "checked": {"1": _NOW - 10}}) + with patch(f"{_PKG}.load_snapshot", return_value=None): + found = skip.skippable_app_ids(_STEAM_ID, [_game(1)], _NOW) + assert found == {1} + + def test_flagged_snapshotted_and_stale_games_are_scanned(self) -> None: + stale = _NOW - skip.RECHECK_AFTER_SECONDS - 1 + _store( + { + "steam_id": _STEAM_ID, + "checked": {"1": _NOW - 10, "2": _NOW - 10, "3": stale, "4": _NOW}, + } + ) + owned = [_game(1, stats=True), _game(2), _game(3), _game(4), _game(5)] + with patch(f"{_PKG}.load_snapshot", return_value=[{"app_id": 2}]): + found = skip.skippable_app_ids(_STEAM_ID, owned, _NOW) + assert found == {4} + + +class TestRecordScan: + def _saved(self) -> dict[str, float]: + return json.loads(skip._cache_file().read_text(encoding="utf-8"))["checked"] + + def test_records_games_that_came_back_empty(self) -> None: + skip.record_scan(_STEAM_ID, [_game(1), _game(2)], {2}, _NOW) + assert self._saved() == {"1": _NOW} + + def test_forgets_games_that_gained_achievements_or_the_flag(self) -> None: + _store({"steam_id": _STEAM_ID, "checked": {"1": 5, "2": 5, "3": 5}}) + fetched = [_game(1), _game(2, stats=True), _game(3)] + skip.record_scan(_STEAM_ID, fetched, {1}, _NOW) + assert self._saved() == {"3": _NOW} + + def test_a_failed_write_is_swallowed(self) -> None: + with patch(f"{_PKG}._atomic_write", side_effect=OSError("disk full")): + skip.record_scan(_STEAM_ID, [_game(1)], set(), _NOW) + assert not skip._cache_file().exists() diff --git a/steam_backlog_enforcer/tests/test_backups.py b/steam_backlog_enforcer/tests/test_backups.py new file mode 100644 index 0000000..1509405 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_backups.py @@ -0,0 +1,116 @@ +"""Tests for _backups: state.json snapshots taken before destructive commands.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest + +from steam_backlog_enforcer import _backups as backups +from steam_backlog_enforcer import config + +if TYPE_CHECKING: + from pathlib import Path + +_OLD_ID = "20260101T000000Z-aaaaaa" +_NEW_ID = "20260202T000000Z-bbbbbb" + + +def _write_state(text: str = '{"current_app_id": 1}') -> None: + config.STATE_FILE.write_text(text, encoding="utf-8") + + +def _plant( + backup_id: str, *, state: str | None = '{"x": 1}', meta: str | None = None +) -> Path: + """Create a backup directory by hand; ``None`` omits that file.""" + directory = backups.BACKUPS_DIR / backup_id + directory.mkdir(parents=True) + if state is not None: + (directory / "state.json").write_text(state, encoding="utf-8") + if meta is not None: + (directory / "meta.json").write_text(meta, encoding="utf-8") + return directory + + +class TestBackupDir: + @pytest.mark.parametrize("bad", ["", "..", "../x", "20260101T000000Z", "a/b"]) + def test_refuses_anything_that_is_not_an_id(self, bad: str) -> None: + with pytest.raises(backups.BackupError, match="Not a backup id"): + backups._backup_dir(bad) + + def test_resolves_a_valid_id(self) -> None: + assert backups._backup_dir(_OLD_ID) == backups.BACKUPS_DIR / _OLD_ID + + +class TestCreateBackup: + def test_no_state_file_means_no_backup(self) -> None: + assert backups.create_backup("why") is None + assert not backups.BACKUPS_DIR.exists() + + def test_copies_state_and_records_why(self) -> None: + _write_state() + backup = backups.create_backup("before reset") + assert backup is not None + assert backup.reason == "before reset" + assert backup.size_bytes == len('{"current_app_id": 1}') + copy = backups.BACKUPS_DIR / backup.id / "state.json" + assert copy.read_text(encoding="utf-8") == '{"current_app_id": 1}' + assert backup.to_json()["id"] == backup.id + + +class TestReadBackup: + def test_missing_meta_is_unreadable(self) -> None: + assert backups._read_backup(_plant(_OLD_ID)) is None + + def test_corrupt_meta_is_unreadable(self) -> None: + assert backups._read_backup(_plant(_OLD_ID, meta="{nope")) is None + + def test_missing_state_is_unreadable(self) -> None: + directory = _plant(_OLD_ID, state=None, meta="{}") + assert backups._read_backup(directory) is None + + def test_absent_meta_fields_default_to_empty(self) -> None: + found = backups._read_backup(_plant(_OLD_ID, meta="{}")) + assert found is not None + assert (found.created_at, found.reason) == ("", "") + + +class TestListBackups: + def test_no_directory_is_empty(self) -> None: + assert backups.list_backups() == [] + + def test_newest_first_and_skips_junk(self) -> None: + _plant(_OLD_ID, meta='{"reason": "old"}') + _plant(_NEW_ID, meta='{"reason": "new"}') + _plant("20260303T000000Z-cccccc") # incomplete: no meta + (backups.BACKUPS_DIR / "not-an-id").mkdir() + assert [b.id for b in backups.list_backups()] == [_NEW_ID, _OLD_ID] + + +class TestRestoreBackup: + def test_replaces_state_and_backs_up_the_old_one(self) -> None: + _plant(_OLD_ID, state='{"current_app_id": 7}', meta="{}") + _write_state('{"current_app_id": 1}') + previous = backups.restore_backup(_OLD_ID) + assert json.loads(config.STATE_FILE.read_text("utf-8")) == {"current_app_id": 7} + assert previous is not None + assert previous.reason == f"before restoring {_OLD_ID}" + + def test_restoring_over_no_state_has_no_previous(self) -> None: + _plant(_OLD_ID, state="{}", meta="{}") + assert backups.restore_backup(_OLD_ID) is None + assert config.STATE_FILE.read_text(encoding="utf-8") == "{}" + + @pytest.mark.parametrize("state", [None, "{broken"]) + def test_unreadable_backup_raises_and_keeps_state(self, state: str | None) -> None: + _plant(_OLD_ID, state=state, meta="{}") + _write_state() + with pytest.raises(backups.BackupError, match="unreadable"): + backups.restore_backup(_OLD_ID) + assert config.STATE_FILE.read_text(encoding="utf-8") == '{"current_app_id": 1}' + + def test_malformed_id_raises(self) -> None: + with pytest.raises(backups.BackupError): + backups.restore_backup("../../etc") diff --git a/steam_backlog_enforcer/tests/test_budget_resolve_reading.py b/steam_backlog_enforcer/tests/test_budget_resolve_reading.py index 21bf954..c5c7a8d 100644 --- a/steam_backlog_enforcer/tests/test_budget_resolve_reading.py +++ b/steam_backlog_enforcer/tests/test_budget_resolve_reading.py @@ -114,7 +114,7 @@ class TestTheBase: day = AFTER_CUT + timedelta(days=400) with pin_today(day): resolved = _resolve(earners=(False, False, False)) - later_cuts = 60 * sum(e.gaming_minutes for e in _later_penalised(day)) + later_cuts = 60 * sum(e.max_gaming_minutes for e in _later_penalised(day)) assert resolved.base_seconds == 4 * _HOUR - later_cuts diff --git a/steam_backlog_enforcer/tests/test_cmd_done_part2.py b/steam_backlog_enforcer/tests/test_cmd_done_part2.py index ee1ac83..c9c4134 100644 --- a/steam_backlog_enforcer/tests/test_cmd_done_part2.py +++ b/steam_backlog_enforcer/tests/test_cmd_done_part2.py @@ -5,83 +5,73 @@ from __future__ import annotations from unittest.mock import patch from steam_backlog_enforcer._cmd_done import _prompt_keep_or_skip +from steam_backlog_enforcer._prompter import use_prompter +from steam_backlog_enforcer.config import State from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._fake_prompter import FakePrompter CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done" +def _game(hours: float = 5.0, app_id: int = 42, name: str = "Test") -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=hours, + ) + + +def _ask(prompter: FakePrompter, game: GameInfo | None = None) -> bool | GameInfo: + """Run the prompt under *prompter*, collecting nothing from the terminal.""" + with use_prompter(prompter), patch(f"{CMD_DONE_PKG}._echo"): + return _prompt_keep_or_skip(game or _game(), [], State()) + + class TestPromptKeepOrSkip: """Tests for _prompt_keep_or_skip.""" - def _game(self, hours: float = 5.0) -> GameInfo: - return GameInfo( - app_id=42, - name="Test", - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=60, - completionist_hours=hours, - ) + def test_non_interactive_accepts_silently(self) -> None: + prompter = FakePrompter(interactive=False) + assert _ask(prompter) is True + assert prompter.asked == [] - def test_non_tty_accepts_silently(self) -> None: - with patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin: - mock_stdin.isatty.return_value = False - assert _prompt_keep_or_skip(self._game()) is True + def test_keep_accepts(self) -> None: + assert _ask(FakePrompter(choices=["keep"])) is True - def test_yes_answers_accept(self) -> None: - for answer in ("y", "Y", "yes", "YES", ""): - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch(f"{CMD_DONE_PKG}._echo"), - patch("builtins.input", return_value=answer), - ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is True, answer + def test_skip_rejects(self) -> None: + assert _ask(FakePrompter(choices=["skip"])) is False - def test_no_answers_reject(self) -> None: - for answer in ("n", "N", "no", "NO"): - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch(f"{CMD_DONE_PKG}._echo"), - patch("builtins.input", return_value=answer), - ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is False, answer + def test_offers_keep_skip_and_own_pick(self) -> None: + prompter = FakePrompter(choices=["keep"]) + _ask(prompter) + assert prompter.asked == [(" Keep this game?", ["keep", "skip", "own"])] - def test_invalid_then_yes(self) -> None: - echoed: list[str] = [] + def test_own_pick_returns_the_chosen_game(self) -> None: + mine = _game(app_id=7, name="Mine") + with patch(f"{CMD_DONE_PKG}.prompt_own_pick", return_value=mine): + assert _ask(FakePrompter(choices=["own"])) is mine + + def test_going_back_from_own_pick_asks_again(self) -> None: + prompter = FakePrompter(choices=["own", "skip"]) + with patch(f"{CMD_DONE_PKG}.prompt_own_pick", return_value=None): + assert _ask(prompter) is False + assert len(prompter.asked) == 2 + + def test_hours_are_shown_when_known(self) -> None: with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch( - f"{CMD_DONE_PKG}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch("builtins.input", side_effect=["maybe", "y"]), + use_prompter(FakePrompter(choices=["keep"])), + patch(f"{CMD_DONE_PKG}._echo") as echo, ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is True - assert any("answer 'y' or 'n'" in line for line in echoed) - - def test_eof_accepts(self) -> None: - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch(f"{CMD_DONE_PKG}._echo"), - patch("builtins.input", side_effect=EOFError), - ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is True + _prompt_keep_or_skip(_game(), [], State()) + assert "(~5.0h leisure+dlc)" in echo.call_args_list[0].args[0] def test_zero_hours_omits_hours_string(self) -> None: - echoed: list[str] = [] with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch( - f"{CMD_DONE_PKG}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch("builtins.input", return_value="y"), + use_prompter(FakePrompter(choices=["keep"])), + patch(f"{CMD_DONE_PKG}._echo") as echo, ): - mock_stdin.isatty.return_value = True - _prompt_keep_or_skip(self._game(hours=0.0)) - # Without hours, the printed line should not contain "~" - assert not any("~" in line for line in echoed if "Next pick" in line) + _prompt_keep_or_skip(_game(hours=0.0), [], State()) + assert "~" not in echo.call_args_list[0].args[0] diff --git a/steam_backlog_enforcer/tests/test_cmd_playtime.py b/steam_backlog_enforcer/tests/test_cmd_playtime.py index f5dc83f..6a1cd8f 100644 --- a/steam_backlog_enforcer/tests/test_cmd_playtime.py +++ b/steam_backlog_enforcer/tests/test_cmd_playtime.py @@ -18,6 +18,8 @@ from steam_backlog_enforcer._playtime_state import ( from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.tests._no_workout_http import fixed_budget +RESET_PKG = "steam_backlog_enforcer._gaming_reset" +_RESET_PHRASE = "reset today's gaming budget" PKG = "steam_backlog_enforcer._cmd_playtime" @@ -146,8 +148,8 @@ class TestCmdGamingReset: with ( patch(f"{PKG}.os.geteuid", return_value=0), patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), - patch(f"{PKG}.release_block", return_value=["/usr/bin/steam"]), + patch("builtins.input", return_value=_RESET_PHRASE), + patch(f"{RESET_PKG}.release_block", return_value=["/usr/bin/steam"]), ): assert cmd_gaming_reset(Config(), State()) == 0 stored = load_state(demo=False) @@ -158,8 +160,8 @@ class TestCmdGamingReset: with ( patch(f"{PKG}.os.geteuid", return_value=0), patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="YES"), - patch(f"{PKG}.release_block", return_value=["/a", "/b"]), + patch("builtins.input", return_value=_RESET_PHRASE), + patch(f"{RESET_PKG}.release_block", return_value=["/a", "/b"]), ): cmd_gaming_reset(Config(), State()) assert "Released 2 mount(s)" in _echoed(mock_echo) diff --git a/steam_backlog_enforcer/tests/test_command_catalog.py b/steam_backlog_enforcer/tests/test_command_catalog.py new file mode 100644 index 0000000..bf9f3c9 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_command_catalog.py @@ -0,0 +1,117 @@ +"""Tests for ``_command_catalog`` and the shared ``_command_locks`` tables.""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer import _command_catalog, _command_locks +from steam_backlog_enforcer._friction import Friction +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.jobs._specs import JobFlags +from steam_backlog_enforcer.main import _shared + + +class TestFrictionJson: + """The contract's FrictionSpec.""" + + def test_none(self) -> None: + assert _command_catalog._friction_json(None) is None + + def test_without_countdown(self) -> None: + assert _command_catalog._friction_json(Friction("do {x}")) == { + "phrase_template": "do {x}" + } + + def test_with_countdown(self) -> None: + assert _command_catalog._friction_json(Friction("do it", 300)) == { + "phrase_template": "do it", + "countdown_seconds": 300, + } + + +class TestSpec: + """One CommandSpec object.""" + + def test_view_uses_entry_flags_and_endpoint(self) -> None: + entry = _command_catalog._view("backlog", "/api/x") + spec = _command_catalog._spec("status", entry, "desc", None) + assert spec["kind"] == "view" + assert spec["view_endpoint"] == "/api/x" + assert spec["mutating"] is False + assert spec["params"] == [] + assert spec["friction"] is None + assert spec["locked_reason"] is None + + def test_job_takes_flags_from_registry(self) -> None: + flags = JobFlags(mutating=False, cancellable=True, privileged=True) + with patch.dict(_command_catalog.JOB_FLAGS, {"scan": flags}): + spec = _command_catalog._spec( + "scan", _command_catalog._job("backlog"), "d", "why" + ) + assert (spec["mutating"], spec["cancellable"], spec["privileged"]) == ( + False, + True, + True, + ) + assert spec["locked_reason"] == "why" + assert "view_endpoint" not in spec + + def test_job_without_registry_entry_falls_back_to_entry(self) -> None: + entry = _command_catalog._Entry("system", "job", mutating=True) + with patch.dict(_command_catalog.JOB_FLAGS, clear=True): + spec = _command_catalog._spec("zzz", entry, "d", None) + assert spec["mutating"] is True + + def test_params_and_friction_are_attached(self) -> None: + spec = _command_catalog._spec( + "block-gaming", _command_catalog._job("gaming"), "d", None + ) + assert spec["params"] == [_command_catalog.PARAMS["block-gaming"][0].to_json()] + assert spec["friction"] == { + "phrase_template": "block all gaming for {days} days" + } + + +class TestCommandSpecs: + """The full payload.""" + + def test_one_spec_per_catalog_entry(self) -> None: + specs = _command_catalog.command_specs(Config(), State()) + assert [s["name"] for s in specs] == list(_command_catalog.CATALOG) + assert all( + isinstance(s["description"], str) and s["description"] for s in specs + ) + + def test_unconfigured_commands_report_a_lock(self) -> None: + specs = { + s["name"]: s for s in _command_catalog.command_specs(Config(), State()) + } + assert specs["setup"]["locked_reason"] is None + assert specs["scan"]["locked_reason"] == "Not configured. Run 'setup' first." + + def test_loads_config_and_state_when_omitted(self) -> None: + with ( + patch.object(_command_catalog.Config, "load", return_value=Config()) as cfg, + patch.object(_command_catalog.State, "load", return_value=State()) as st, + ): + _command_catalog.command_specs() + cfg.assert_called_once() + st.assert_called_once() + + +class TestCommandLocks: + """The daemon and the CLI consult one table.""" + + def test_cli_uses_the_same_tables(self) -> None: + assert _shared._MANUAL_LOCK_EXEMPT_COMMANDS is ( + _command_locks._MANUAL_LOCK_EXEMPT_COMMANDS + ) + assert _shared._TOTAL_BLOCK_EXEMPT_COMMANDS is ( + _command_locks._TOTAL_BLOCK_EXEMPT_COMMANDS + ) + + def test_total_block_is_stricter(self) -> None: + assert _command_locks._TOTAL_BLOCK_EXEMPT_COMMANDS <= ( + _command_locks._MANUAL_LOCK_EXEMPT_COMMANDS + ) + assert "gaming-reset" not in _command_locks._TOTAL_BLOCK_EXEMPT_COMMANDS diff --git a/steam_backlog_enforcer/tests/test_command_gate.py b/steam_backlog_enforcer/tests/test_command_gate.py new file mode 100644 index 0000000..e5d99e0 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_command_gate.py @@ -0,0 +1,67 @@ +"""Tests for _command_gate: the lock rules shared by the CLI and web jobs.""" + +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _command_gate as _gate +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.tests._main_helpers import locked_state + +if TYPE_CHECKING: + from collections.abc import Iterator + +_PKG = "steam_backlog_enforcer._command_gate" +_CONFIGURED = Config(steam_api_key="k", steam_id="i") + + +@contextmanager +def _block(*, active: bool, days: float = 3.5) -> Iterator[None]: + """Patch the total-block status the gate reads.""" + status = MagicMock(days_remaining=days) + with ( + patch(f"{_PKG}.is_total_block_active", return_value=active), + patch(f"{_PKG}.get_total_block_status", return_value=status), + ): + yield + + +class TestLockReason: + def test_unconfigured_is_refused(self) -> None: + assert _gate.lock_reason("status", Config(), State()) == ( + _gate.NOT_CONFIGURED_MSG + ) + + @pytest.mark.parametrize("command", ["setup", "add-exception"]) + def test_setup_commands_need_no_key(self, command: str) -> None: + with _block(active=False): + assert _gate.lock_reason(command, Config(), State()) is None + + def test_total_block_names_days_and_allowed_commands(self) -> None: + with _block(active=True): + reason = _gate.lock_reason("scan", _CONFIGURED, State()) + assert reason is not None + assert "Total gaming block active (3.5 day(s) left)" in reason + assert "Allowed:" in reason + + def test_exempt_commands_pass_the_total_block(self) -> None: + with _block(active=True): + assert _gate.lock_reason("status", _CONFIGURED, State()) is None + + def test_manual_pick_lock_names_the_picks(self) -> None: + with _block(active=False): + reason = _gate.lock_reason("scan", _CONFIGURED, locked_state()) + assert reason is not None + assert "Manual pick lock active (TestGame)" in reason + + def test_exempt_commands_pass_the_pick_lock(self) -> None: + with _block(active=False): + assert _gate.lock_reason("status", _CONFIGURED, locked_state()) is None + + def test_no_state_skips_the_pick_check(self) -> None: + with _block(active=False): + assert _gate.lock_reason("scan", _CONFIGURED, None) is None diff --git a/steam_backlog_enforcer/tests/test_command_params.py b/steam_backlog_enforcer/tests/test_command_params.py new file mode 100644 index 0000000..cdc3bce --- /dev/null +++ b/steam_backlog_enforcer/tests/test_command_params.py @@ -0,0 +1,98 @@ +"""Tests for ``_command_params``: the typed param table and its validation.""" + +from __future__ import annotations + +import pytest + +from steam_backlog_enforcer._command_params import ( + PARAMS, + InvalidParamsError, + ParamSpec, + validate_params, +) + + +class TestToJson: + """Contract serialisation omits unset optional keys.""" + + def test_minimal(self) -> None: + spec = ParamSpec("n", "N", "int") + assert spec.to_json() == { + "name": "n", + "label": "N", + "type": "int", + "required": True, + } + + def test_full(self) -> None: + spec = ParamSpec( + "n", "N", "int", required=False, min=0, max=9, default=0, help="h" + ) + assert spec.to_json() == { + "name": "n", + "label": "N", + "type": "int", + "required": False, + "min": 0, + "max": 9, + "default": 0, + "help": "h", + } + + +class TestValidateParams: + """Coercion, bounds, defaults and unknown/missing params.""" + + def test_command_without_params(self) -> None: + assert validate_params("status", {}) == {} + + def test_unknown_param(self) -> None: + with pytest.raises(InvalidParamsError, match=r"Unknown param.*x, y"): + validate_params("status", {"y": 1, "x": 2}) + + def test_missing_required(self) -> None: + with pytest.raises(InvalidParamsError, match="Missing required param: days"): + validate_params("block-gaming", {}) + + def test_default_filled(self) -> None: + assert validate_params("unblock", {}) == {"minutes": 15} + + def test_optional_without_default_is_omitted(self) -> None: + optional = ParamSpec("o", "O", "string", required=False) + with pytest.MonkeyPatch.context() as patch: + patch.setitem(PARAMS, "probe", (optional,)) + assert validate_params("probe", {}) == {} + + def test_int_from_string(self) -> None: + assert validate_params("block-gaming", {"days": " 7 "}) == {"days": 7} + + def test_string_is_stripped(self) -> None: + assert validate_params("restore-backup", {"backup_id": " b1 "}) == { + "backup_id": "b1" + } + + @pytest.mark.parametrize("raw", ["", " ", 5, None]) + def test_string_must_be_text(self, raw: object) -> None: + with pytest.raises(InvalidParamsError, match="non-empty string"): + validate_params("restore-backup", {"backup_id": raw}) + + @pytest.mark.parametrize("raw", [True, 1.5, None, [1]]) + def test_int_must_be_integer_like(self, raw: object) -> None: + with pytest.raises(InvalidParamsError, match="whole number"): + validate_params("block-gaming", {"days": raw}) + + def test_int_text_must_parse(self) -> None: + with pytest.raises(InvalidParamsError, match="got 'abc'"): + validate_params("block-gaming", {"days": "abc"}) + + def test_below_min(self) -> None: + with pytest.raises(InvalidParamsError, match="at least 1"): + validate_params("block-gaming", {"days": 0}) + + def test_above_max(self) -> None: + with pytest.raises(InvalidParamsError, match="at most 30"): + validate_params("unblock", {"minutes": 31}) + + def test_boundaries_accepted(self) -> None: + assert validate_params("unblock", {"minutes": 30}) == {"minutes": 30} + assert validate_params("enforce", {"demo": 0}) == {"demo": 0} diff --git a/steam_backlog_enforcer/tests/test_ctl_client.py b/steam_backlog_enforcer/tests/test_ctl_client.py new file mode 100644 index 0000000..7e08c6e --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_client.py @@ -0,0 +1,180 @@ +"""Tests for ``_ctl_client``: the raw call and the typed per-op wrappers. + +``call`` is exercised against a one-shot fake daemon on a Unix socket in a +short temp dir; the typed wrappers patch ``call`` and check what they send and +how they shape the answer. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _ctl_client +from steam_backlog_enforcer._ctl_protocol import ( + MAX_RESPONSE_BYTES, + CtlError, + DaemonUnreachableError, +) +from steam_backlog_enforcer._ctl_types import ( + BlockResult, + DaemonInfo, + PendingReset, + ResetResult, + StoreWindow, +) +from steam_backlog_enforcer.tests._ctl_fixtures import serve_once, short_socket_dir + +if TYPE_CHECKING: + from collections.abc import Iterator + from contextlib import AbstractContextManager + from pathlib import Path + + +@pytest.fixture +def sock_path() -> Iterator[Path]: + with short_socket_dir() as directory: + yield directory / "c.sock" + + +def _line(obj: object) -> bytes: + return json.dumps(obj).encode() + b"\n" + + +class TestCall: + """The wire round trip.""" + + def test_returns_data(self, sock_path: Path) -> None: + thread = serve_once(sock_path, _line({"ok": True, "data": {"a": 1}})) + assert _ctl_client.call("ping", {"x": 1}, path=sock_path) == {"a": 1} + thread.join(5) + + def test_ok_without_object_data_is_empty(self, sock_path: Path) -> None: + serve_once(sock_path, _line({"ok": True, "data": 5})) + assert _ctl_client.call("ping", path=sock_path) == {} + + def test_error_reply_raises_ctl_error(self, sock_path: Path) -> None: + reply = {"ok": False, "error": "locked", "message": "no", "data": {"k": 1}} + serve_once(sock_path, _line(reply)) + with pytest.raises(CtlError) as caught: + _ctl_client.call("ping", path=sock_path) + assert (caught.value.code, caught.value.message) == ("locked", "no") + assert caught.value.data == {"k": 1} + + def test_error_reply_defaults(self, sock_path: Path) -> None: + serve_once(sock_path, _line({"ok": False})) + with pytest.raises(CtlError) as caught: + _ctl_client.call("ping", path=sock_path) + assert (caught.value.code, caught.value.message) == ("op_failed", "") + assert caught.value.data == {} + + def test_missing_socket(self, sock_path: Path) -> None: + with pytest.raises(DaemonUnreachableError, match="control socket"): + _ctl_client.call("ping", path=sock_path) + + def test_closed_without_answer(self, sock_path: Path) -> None: + serve_once(sock_path, b"") + with pytest.raises(DaemonUnreachableError, match="without answering"): + _ctl_client.call("ping", path=sock_path) + + @pytest.mark.parametrize("reply", [b"{nope\n", b"[1]\n", b"\xff\n"]) + def test_malformed_reply(self, sock_path: Path, reply: bytes) -> None: + serve_once(sock_path, reply) + with pytest.raises(DaemonUnreachableError, match="malformed"): + _ctl_client.call("ping", path=sock_path) + + def test_oversize_reply(self, sock_path: Path) -> None: + serve_once(sock_path, b"x" * (MAX_RESPONSE_BYTES + 70000)) + with pytest.raises(DaemonUnreachableError, match="too large"): + _ctl_client.call("ping", path=sock_path) + + +_PENDING = { + "pending_id": "abc", + "phrase": "p", + "armed_at": "a", + "ready_at": "r", + "heartbeat_interval_seconds": 10, +} + + +class TestTypedWrappers: + """Each wrapper sends the right op and shapes the reply.""" + + def _call(self, data: dict[str, object]) -> AbstractContextManager[MagicMock]: + return patch.object(_ctl_client, "call", return_value=data) + + def test_ping(self) -> None: + with self._call({}) as call: + _ctl_client.ping() + call.assert_called_once_with("ping") + + def test_status(self) -> None: + data = { + "state": "running", + "started_at": "t", + "pid": 4, + "restart_available_at": "x", + } + with self._call(data): + assert _ctl_client.status() == DaemonInfo("running", "t", 4, "x") + + def test_status_no_restart_time(self) -> None: + data = {"state": "running", "started_at": "t", "pid": 4} + with self._call(data): + assert _ctl_client.status().restart_available_at is None + + def test_journal_tail(self) -> None: + with self._call({"lines": ["a", 2]}) as call: + assert _ctl_client.journal_tail(9) == ["a", "2"] + call.assert_called_once_with("journal_tail", {"lines": 9}) + + def test_store_unblock(self) -> None: + with self._call({"minutes": 5, "until": "u"}) as call: + assert _ctl_client.store_unblock(5, "ph") == StoreWindow(5, "u") + assert call.call_args.args == ("store_unblock", {"minutes": 5, "phrase": "ph"}) + + def test_block_gaming(self) -> None: + with self._call({"days": 3, "until": "u"}): + assert _ctl_client.block_gaming(3, "ph") == BlockResult(3, "u") + + def test_block_gaming_no_until(self) -> None: + with self._call({"days": 3, "until": None}): + assert _ctl_client.block_gaming(3, "ph") == BlockResult(3, None) + + def test_gaming_unblock(self) -> None: + with self._call({"released": ["/a"]}): + assert _ctl_client.gaming_unblock("ph") == ["/a"] + + def test_reset_arm_and_heartbeat(self) -> None: + expected = PendingReset("abc", "p", "a", "r", 10) + with self._call(dict(_PENDING)): + assert _ctl_client.gaming_reset_arm("ph") == expected + assert _ctl_client.gaming_reset_heartbeat("abc") == expected + + def test_reset_commit(self) -> None: + data = { + "day_key": "d", + "seconds_before": 1.5, + "was_blocked": 1, + "released": ["/m"], + } + with self._call(data): + result = _ctl_client.gaming_reset_commit("abc", "ph") + assert result == ResetResult("d", 1.5, was_blocked=True, released=["/m"]) + + def test_reset_cancel(self) -> None: + with self._call({}) as call: + _ctl_client.gaming_reset_cancel("abc") + call.assert_called_once_with("gaming_reset_cancel", {"pending_id": "abc"}) + + def test_restart(self) -> None: + with self._call({"restart_available_at": "x"}): + assert _ctl_client.restart() == "x" + + def test_restart_no_time(self) -> None: + with self._call({}): + assert _ctl_client.restart() is None diff --git a/steam_backlog_enforcer/tests/test_ctl_context.py b/steam_backlog_enforcer/tests/test_ctl_context.py new file mode 100644 index 0000000..ea8c3c7 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_context.py @@ -0,0 +1,139 @@ +"""Tests for ``_ctl_context``: the tick lock, argument readers and lock gate.""" + +from __future__ import annotations + +from contextlib import AbstractContextManager, ExitStack +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _ctl_context +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx + + +def _code(exc: pytest.ExceptionInfo[CtlError]) -> str: + return exc.value.code + + +class TestHoldTick: + """The lock is held for the body and always released.""" + + def test_holds_and_releases(self) -> None: + ctx = make_ctx() + with _ctl_context.hold_tick(ctx): + assert ctx.tick_lock.locked() + assert not ctx.tick_lock.locked() + + def test_releases_after_error(self) -> None: + ctx = make_ctx() + with pytest.raises(RuntimeError), _ctl_context.hold_tick(ctx): + msg = "boom" + raise RuntimeError(msg) + assert not ctx.tick_lock.locked() + + def test_busy_when_loop_keeps_the_lock(self) -> None: + ctx = make_ctx() + ctx.tick_lock.acquire() + with ( + patch.object(_ctl_context, "LOCK_WAIT_SECONDS", 0.01), + pytest.raises(CtlError) as caught, + _ctl_context.hold_tick(ctx), + ): + pass + assert _code(caught) == "busy" + + +class TestRequirePhrase: + """The typed confirmation phrase.""" + + def test_accepts_exact_phrase_with_padding(self) -> None: + _ctl_context.require_phrase( + "block-gaming", " block all gaming for 3 days ", days=3 + ) + + def test_non_string(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_context.require_phrase("gaming-unblock", 5) + assert _code(caught) == "invalid_params" + + def test_wrong_phrase(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_context.require_phrase("gaming-unblock", "nope") + assert _code(caught) == "wrong_phrase" + + def test_command_without_phrase_is_never_accepted(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_context.require_phrase("status", "") + assert _code(caught) == "wrong_phrase" + + +class TestIntArg: + """Bounded integer arguments.""" + + def test_valid(self) -> None: + assert _ctl_context.int_arg({"n": 5}, "n", 1, 10) == 5 + + @pytest.mark.parametrize("value", [True, "5", 5.0, None]) + def test_wrong_type(self, value: object) -> None: + with pytest.raises(CtlError, match="must be an integer"): + _ctl_context.int_arg({"n": value}, "n", 1, 10) + + def test_missing(self) -> None: + with pytest.raises(CtlError, match="must be an integer"): + _ctl_context.int_arg({}, "n", 1, 10) + + @pytest.mark.parametrize("value", [0, 11]) + def test_out_of_range(self, value: int) -> None: + with pytest.raises(CtlError, match="between 1 and 10"): + _ctl_context.int_arg({"n": value}, "n", 1, 10) + + +class TestStrArg: + """Non-empty string arguments.""" + + def test_valid(self) -> None: + assert _ctl_context.str_arg({"s": "abc"}, "s") == "abc" + + @pytest.mark.parametrize("value", ["", 5, None]) + def test_invalid(self, value: object) -> None: + with pytest.raises(CtlError, match="non-empty string"): + _ctl_context.str_arg({"s": value}, "s") + + +def _locks(*, block: bool, manual: bool) -> AbstractContextManager[object]: + """Pin both lock predicates.""" + stack = ExitStack() + stack.enter_context( + patch.object(_ctl_context, "is_total_block_active", return_value=block) + ) + stack.enter_context( + patch.object(_ctl_context, "is_manual_pick_locked", return_value=manual) + ) + return stack + + +class TestCheckLocks: + """The socket honours the same locks as the CLI.""" + + def test_nothing_locked(self) -> None: + with _locks(block=False, manual=False): + _ctl_context.check_locks("block-gaming") + + def test_total_block_refuses(self) -> None: + with _locks(block=True, manual=False), pytest.raises(CtlError) as caught: + _ctl_context.check_locks("gaming-reset") + assert _code(caught) == "locked" + + def test_total_block_exempt_command(self) -> None: + with _locks(block=True, manual=False): + _ctl_context.check_locks("gaming-unblock") + + def test_manual_pick_lock_refuses(self) -> None: + with _locks(block=False, manual=True), pytest.raises(CtlError) as caught: + _ctl_context.check_locks("unblock") + assert _code(caught) == "locked" + + def test_manual_pick_exempt_command(self) -> None: + with _locks(block=False, manual=True): + _ctl_context.check_locks("gaming-reset") diff --git a/steam_backlog_enforcer/tests/test_ctl_control.py b/steam_backlog_enforcer/tests/test_ctl_control.py new file mode 100644 index 0000000..6f8f80c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_control.py @@ -0,0 +1,131 @@ +"""Tests for ``_ctl_control``: the enforce loop's handle on the socket. + +``os.geteuid``, the desktop-user lookup and the server are mocked; no socket +is ever bound and no tick is actually billed. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _ctl_control +from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx + + +class TestDaemonControl: + """The handle the loop holds.""" + + def test_properties(self) -> None: + ctx = make_ctx() + control = _ctl_control.DaemonControl(ctx, None) + assert control.tick_lock is ctx.tick_lock + assert control.restart_requested is False + ctx.restart_event.set() + assert control.restart_requested is True + + def test_flush_without_request_does_nothing(self) -> None: + control = _ctl_control.DaemonControl(make_ctx(), None) + with patch.object(_ctl_control, "playtime_tick") as tick: + assert ( + control.flush_for_restart( + Config(), MagicMock(), interval=3.0, demo=False + ) + is False + ) + tick.assert_not_called() + + def test_flush_bills_marks_and_exits(self) -> None: + ctx = make_ctx() + ctx.restart_event.set() + control = _ctl_control.DaemonControl(ctx, None) + config, session = Config(), MagicMock() + with ( + patch.object(_ctl_control, "playtime_tick") as tick, + patch.object(_ctl_control, "record_exit") as record, + ): + assert control.flush_for_restart(config, session, interval=3.0, demo=False) + tick.assert_called_once_with(config, interval=3.0, session=session, demo=False) + record.assert_called_once() + + def test_close_stops_server(self) -> None: + server = MagicMock() + _ctl_control.DaemonControl(make_ctx(), server).close() + server.stop.assert_called_once() + + def test_close_inert(self) -> None: + _ctl_control.DaemonControl(make_ctx(), None).close() + + +class TestIsSupervised: + """systemd sets INVOCATION_ID for its services.""" + + def test_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("INVOCATION_ID", "abc") + assert _ctl_control._is_supervised() is True + + def test_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("INVOCATION_ID", raising=False) + assert _ctl_control._is_supervised() is False + + +class TestStartControl: + """Only the real root daemon serves.""" + + def test_demo_is_inert(self) -> None: + with patch.object(_ctl_control, "CtlServer") as server: + control = _ctl_control.start_control(Config(), demo=True) + server.assert_not_called() + assert control._server is None + + def test_non_root_is_inert(self) -> None: + with ( + patch.object(_ctl_control.os, "geteuid", return_value=1000), + patch.object(_ctl_control, "CtlServer") as server, + ): + control = _ctl_control.start_control(Config(), demo=False) + server.assert_not_called() + assert control._server is None + + @pytest.mark.parametrize("user", [None, "ghost"]) + def test_unknown_desktop_user_fails_closed(self, user: str | None) -> None: + with ( + patch.object(_ctl_control.os, "geteuid", return_value=0), + patch.object(_ctl_control, "resolve_desktop_user", return_value=user), + patch.object(_ctl_control.pwd, "getpwnam", side_effect=KeyError(user)), + patch.object(_ctl_control, "CtlServer") as server, + ): + control = _ctl_control.start_control(Config(), demo=False) + server.assert_not_called() + assert control._server is None + + def test_root_desktop_user_fails_closed(self) -> None: + entry = MagicMock(pw_uid=0, pw_gid=0) + with ( + patch.object(_ctl_control.os, "geteuid", return_value=0), + patch.object(_ctl_control, "resolve_desktop_user", return_value="root"), + patch.object(_ctl_control.pwd, "getpwnam", return_value=entry), + patch.object(_ctl_control, "CtlServer") as server, + ): + control = _ctl_control.start_control(Config(), demo=False) + server.assert_not_called() + assert control._server is None + + @pytest.mark.parametrize("started", [True, False], ids=["bound", "bind_failed"]) + def test_serves_for_desktop_user(self, *, started: bool) -> None: + entry = MagicMock(pw_uid=1000, pw_gid=1001) + with ( + patch.object(_ctl_control.os, "geteuid", return_value=0), + patch.object(_ctl_control, "resolve_desktop_user", return_value="kuhy"), + patch.object(_ctl_control.pwd, "getpwnam", return_value=entry), + patch.object(_ctl_control, "CtlServer") as server_cls, + ): + server_cls.return_value.start.return_value = started + control = _ctl_control.start_control(Config(), demo=False) + assert server_cls.call_args.kwargs == { + "desktop_uid": 1000, + "desktop_gid": 1001, + } + assert (control._server is not None) is started diff --git a/steam_backlog_enforcer/tests/test_ctl_ops.py b/steam_backlog_enforcer/tests/test_ctl_ops.py new file mode 100644 index 0000000..529d114 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_ops.py @@ -0,0 +1,114 @@ +"""Tests for the allowlisted ops in ``_ctl_ops``. + +Every privileged collaborator (journalctl, the store window, the total block, +the playtime mounts) is mocked; state-file ownership is checked against +tmp_path files and a mocked ``os.chown``. +""" + +from __future__ import annotations + +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _ctl_ops +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx + +if TYPE_CHECKING: + from pathlib import Path + + +class TestSimpleOps: + """ping and status.""" + + def test_ping(self) -> None: + assert _ctl_ops.op_ping(make_ctx(), {}) == {"pong": True} + + def test_status_running(self) -> None: + with patch.object(_ctl_ops, "restart_available_at", return_value=None): + data = _ctl_ops.op_status(make_ctx(), {}) + assert data["state"] == "running" + assert data["started_at"] == "2026-01-01T00:00:00+00:00" + assert data["restart_available_at"] is None + assert isinstance(data["pid"], int) + + def test_status_restarting(self) -> None: + ctx = make_ctx() + ctx.restart_event.set() + assert _ctl_ops.op_status(ctx, {})["state"] == "restarting" + + +class TestJournalTail: + """journal_tail shells out to journalctl with a capped line count.""" + + def _run(self, **kwargs: object) -> MagicMock: + return MagicMock(**kwargs) + + def test_default_lines_and_truncation(self) -> None: + out = "a\n" + "x" * 900 + "\n" + with patch.object( + _ctl_ops.subprocess, "run", return_value=self._run(stdout=out) + ) as run: + data = _ctl_ops.op_journal_tail(make_ctx(), {}) + assert data["lines"] == ["a", "x" * 500] + argv = run.call_args.args[0] + assert argv[argv.index("-n") + 1] == "50" + + def test_explicit_lines(self) -> None: + with patch.object( + _ctl_ops.subprocess, "run", return_value=self._run(stdout="") + ) as run: + _ctl_ops.op_journal_tail(make_ctx(), {"lines": 7}) + assert "7" in run.call_args.args[0] + + def test_lines_out_of_range(self) -> None: + with pytest.raises(CtlError): + _ctl_ops.op_journal_tail(make_ctx(), {"lines": 201}) + + @pytest.mark.parametrize( + "exc", [OSError("gone"), subprocess.TimeoutExpired("journalctl", 5)] + ) + def test_failure_is_op_failed(self, exc: Exception) -> None: + with ( + patch.object(_ctl_ops.subprocess, "run", side_effect=exc), + pytest.raises(CtlError) as caught, + ): + _ctl_ops.op_journal_tail(make_ctx(), {}) + assert caught.value.code == "op_failed" + + +class TestHandBackStateFile: + """A state.json root just created is chowned to the config dir's owner.""" + + def test_chowns_when_owner_differs(self, tmp_path: Path) -> None: + state = tmp_path / "state.json" + state.write_text("{}") + owner = MagicMock(st_uid=4242, st_gid=4343) + with ( + patch.object(_ctl_ops, "CONFIG_DIR", MagicMock(stat=lambda: owner)), + patch.object(_ctl_ops, "STATE_FILE", state), + patch.object(_ctl_ops.os, "chown") as chown, + ): + _ctl_ops._hand_back_state_file() + chown.assert_called_once_with(state, 4242, 4343) + + def test_leaves_matching_owner_alone(self, tmp_path: Path) -> None: + state = tmp_path / "state.json" + state.write_text("{}") + with ( + patch.object(_ctl_ops, "CONFIG_DIR", tmp_path), + patch.object(_ctl_ops, "STATE_FILE", state), + patch.object(_ctl_ops.os, "chown") as chown, + ): + _ctl_ops._hand_back_state_file() + chown.assert_not_called() + + def test_missing_file_is_tolerated(self, tmp_path: Path) -> None: + with ( + patch.object(_ctl_ops, "CONFIG_DIR", tmp_path), + patch.object(_ctl_ops, "STATE_FILE", tmp_path / "absent.json"), + ): + _ctl_ops._hand_back_state_file() diff --git a/steam_backlog_enforcer/tests/test_ctl_ops_privileged.py b/steam_backlog_enforcer/tests/test_ctl_ops_privileged.py new file mode 100644 index 0000000..7a426c9 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_ops_privileged.py @@ -0,0 +1,174 @@ +"""Tests for the mutating ops in ``_ctl_ops``: store window, block, unblock. + +Each op re-checks its phrase, bounds and the CLI's locks. The lock gate and +the privileged collaborators are mocked, so nothing here opens a store window, +engages a block or touches a mount. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _ctl_ops +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx + +if TYPE_CHECKING: + from collections.abc import Iterator + +_UNTIL = datetime(2026, 2, 1, 12, 0, tzinfo=UTC) +_UNBLOCK_PHRASE = "unblock the store for 5 minutes" + + +@pytest.fixture(autouse=True) +def hand_back() -> Iterator[MagicMock]: + """The chown helper is covered elsewhere; keep it away from real paths.""" + with patch.object(_ctl_ops, "_hand_back_state_file") as hand_back: + yield hand_back + + +class TestStoreUnblock: + """store_unblock opens the timed store window.""" + + def test_opens_window(self, hand_back: MagicMock) -> None: + ctx = make_ctx() + with ( + patch.object(_ctl_ops, "check_locks") as locks, + patch.object(_ctl_ops, "open_store_window", return_value=_UNTIL) as opened, + ): + data = _ctl_ops.op_store_unblock( + ctx, {"minutes": 5, "phrase": _UNBLOCK_PHRASE} + ) + assert data == {"minutes": 5, "until": _UNTIL.isoformat()} + locks.assert_called_once_with("unblock") + assert opened.call_args.args[1] == 5 + hand_back.assert_called_once() + assert not ctx.tick_lock.locked() + + def test_wrong_phrase(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_ops.op_store_unblock(make_ctx(), {"minutes": 5, "phrase": "no"}) + assert caught.value.code == "wrong_phrase" + + def test_minutes_out_of_range(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_ops.op_store_unblock(make_ctx(), {"minutes": 99, "phrase": "x"}) + assert caught.value.code == "invalid_params" + + def test_runtime_error_is_op_failed_and_still_hands_back( + self, hand_back: MagicMock + ) -> None: + with ( + patch.object(_ctl_ops, "check_locks"), + patch.object(_ctl_ops, "open_store_window", side_effect=RuntimeError("x")), + pytest.raises(CtlError) as caught, + ): + _ctl_ops.op_store_unblock( + make_ctx(), {"minutes": 5, "phrase": _UNBLOCK_PHRASE} + ) + assert caught.value.code == "op_failed" + hand_back.assert_called_once() + + def test_locked(self) -> None: + lock = CtlError("locked", "a total gaming block is active") + with ( + patch.object(_ctl_ops, "check_locks", side_effect=lock), + pytest.raises(CtlError) as caught, + ): + _ctl_ops.op_store_unblock( + make_ctx(), {"minutes": 5, "phrase": _UNBLOCK_PHRASE} + ) + assert caught.value.code == "locked" + + +class TestBlockGaming: + """block_gaming starts the (irreversible) total block.""" + + _ARGS: ClassVar[dict[str, object]] = { + "days": 3, + "phrase": "block all gaming for 3 days", + } + + def test_starts_block(self) -> None: + status = MagicMock(until=_UNTIL) + with ( + patch.object(_ctl_ops, "check_locks") as locks, + patch.object(_ctl_ops, "start_total_block", return_value=True) as start, + patch.object(_ctl_ops, "get_total_block_status", return_value=status), + ): + data = _ctl_ops.op_block_gaming(make_ctx(), self._ARGS) + assert data == {"days": 3, "until": _UNTIL.isoformat()} + start.assert_called_once_with(3) + locks.assert_called_once_with("block-gaming") + + def test_until_unknown(self) -> None: + with ( + patch.object(_ctl_ops, "check_locks"), + patch.object(_ctl_ops, "start_total_block", return_value=True), + patch.object( + _ctl_ops, "get_total_block_status", return_value=MagicMock(until=None) + ), + ): + data = _ctl_ops.op_block_gaming(make_ctx(), self._ARGS) + assert data["until"] is None + + def test_failure_is_op_failed(self) -> None: + with ( + patch.object(_ctl_ops, "check_locks"), + patch.object(_ctl_ops, "start_total_block", return_value=False), + pytest.raises(CtlError) as caught, + ): + _ctl_ops.op_block_gaming(make_ctx(), self._ARGS) + assert caught.value.code == "op_failed" + + def test_days_capped(self) -> None: + args = {"days": _ctl_ops.MAX_BLOCK_DAYS + 1, "phrase": "x"} + with pytest.raises(CtlError) as caught: + _ctl_ops.op_block_gaming(make_ctx(), args) + assert caught.value.code == "invalid_params" + + +class TestGamingUnblock: + """gaming_unblock releases every playtime mount.""" + + def test_releases(self) -> None: + with ( + patch.object(_ctl_ops, "check_locks") as locks, + patch.object( + _ctl_ops, "release_block", return_value=[Path("/a"), Path("/b")] + ), + ): + data = _ctl_ops.op_gaming_unblock( + make_ctx(), {"phrase": "force release playtime mounts"} + ) + assert data == {"released": ["/a", "/b"]} + locks.assert_called_once_with("gaming-unblock") + + def test_wrong_phrase(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_ops.op_gaming_unblock(make_ctx(), {"phrase": ""}) + assert caught.value.code == "wrong_phrase" + + +class TestOpsTable: + """The allowlist is exactly the documented ops.""" + + def test_names(self) -> None: + assert set(_ctl_ops.OPS) == { + "ping", + "status", + "journal_tail", + "store_unblock", + "block_gaming", + "gaming_unblock", + "gaming_reset_arm", + "gaming_reset_heartbeat", + "gaming_reset_commit", + "gaming_reset_cancel", + "restart", + } diff --git a/steam_backlog_enforcer/tests/test_ctl_pending.py b/steam_backlog_enforcer/tests/test_ctl_pending.py new file mode 100644 index 0000000..14ab6ff --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_pending.py @@ -0,0 +1,184 @@ +"""Tests for the two-phase reset bookkeeping in ``_ctl_pending``. + +A fake monotonic clock drives every countdown; no test sleeps. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +import pytest + +from steam_backlog_enforcer import _ctl_pending +from steam_backlog_enforcer._ctl_protocol import ( + COUNTDOWN_RUNNING, + PENDING_LAPSED, + CtlError, +) + +_WALL = datetime(2026, 1, 1, 12, 0, tzinfo=UTC) + + +class _Clock: + """A settable monotonic clock.""" + + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now + + +@pytest.fixture +def clock() -> _Clock: + return _Clock() + + +@pytest.fixture +def pending(clock: _Clock) -> _ctl_pending.PendingResets: + return _ctl_pending.PendingResets(clock=clock, wall=lambda: _WALL) + + +class TestCountdownSeconds: + """The VM-only override can shorten the countdown, never lengthen it.""" + + def test_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(_ctl_pending.TEST_COUNTDOWN_ENV, raising=False) + assert _ctl_pending.countdown_seconds() == 300 + + def test_shortened(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(_ctl_pending.TEST_COUNTDOWN_ENV, "7") + assert _ctl_pending.countdown_seconds() == 7 + + @pytest.mark.parametrize("raw", ["0", "300", "9999", "abc", "-5", ""]) + def test_invalid_or_longer_is_ignored( + self, monkeypatch: pytest.MonkeyPatch, raw: str + ) -> None: + monkeypatch.setenv(_ctl_pending.TEST_COUNTDOWN_ENV, raw) + assert _ctl_pending.countdown_seconds() == 300 + + +class TestArm: + """Arming starts one countdown and never restarts it.""" + + def test_arm_sets_deadlines( + self, pending: _ctl_pending.PendingResets, clock: _Clock + ) -> None: + armed = pending.arm() + assert armed.armed_at == _WALL + assert armed.ready_mono == clock.now + 300 + assert (armed.ready_at - _WALL).total_seconds() == 300 + assert len(armed.pending_id) == 32 + + def test_rearm_returns_the_live_one( + self, pending: _ctl_pending.PendingResets, clock: _Clock + ) -> None: + first = pending.arm() + clock.now += 5 + assert pending.arm() is first + + def test_rearm_after_lapse_is_new( + self, pending: _ctl_pending.PendingResets, clock: _Clock + ) -> None: + first = pending.arm() + clock.now += _ctl_pending.LAPSE_AFTER_SECONDS + 1 + second = pending.arm() + assert second.pending_id != first.pending_id + + +class TestBeat: + """Heartbeats keep the reset alive.""" + + def test_beat_extends_life( + self, pending: _ctl_pending.PendingResets, clock: _Clock + ) -> None: + armed = pending.arm() + clock.now += 10 + beat = pending.beat(armed.pending_id) + assert beat.last_beat_mono == clock.now + assert beat.ready_mono == armed.ready_mono + clock.now += 10 + assert pending.beat(armed.pending_id).pending_id == armed.pending_id + + def test_beat_after_lapse_fails( + self, pending: _ctl_pending.PendingResets, clock: _Clock + ) -> None: + armed = pending.arm() + clock.now += _ctl_pending.LAPSE_AFTER_SECONDS + 1 + with pytest.raises(CtlError) as caught: + pending.beat(armed.pending_id) + assert caught.value.code == PENDING_LAPSED + + def test_beat_unknown_id_fails(self, pending: _ctl_pending.PendingResets) -> None: + pending.arm() + with pytest.raises(CtlError) as caught: + pending.beat("not-the-id") + assert caught.value.code == PENDING_LAPSED + + def test_beat_non_ascii_id_does_not_raise_typeerror( + self, pending: _ctl_pending.PendingResets + ) -> None: + pending.arm() + with pytest.raises(CtlError): + pending.beat("é") + + def test_beat_with_nothing_armed(self, pending: _ctl_pending.PendingResets) -> None: + with pytest.raises(CtlError) as caught: + pending.beat("x") + assert caught.value.code == PENDING_LAPSED + + +class TestCheckReady: + """Commit is allowed only once the countdown is over.""" + + def test_early_reports_remaining( + self, pending: _ctl_pending.PendingResets, clock: _Clock + ) -> None: + armed = pending.arm() + for _ in range(10): + clock.now += 10 + pending.beat(armed.pending_id) + with pytest.raises(CtlError) as caught: + pending.check_ready(armed.pending_id) + assert caught.value.code == COUNTDOWN_RUNNING + assert caught.value.data == {"retry_after_seconds": 200.0} + + def test_ready_after_countdown( + self, pending: _ctl_pending.PendingResets, clock: _Clock + ) -> None: + armed = pending.arm() + for _ in range(30): + clock.now += 10 + pending.beat(armed.pending_id) + assert pending.check_ready(armed.pending_id).pending_id == armed.pending_id + + def test_unknown_id(self, pending: _ctl_pending.PendingResets) -> None: + with pytest.raises(CtlError) as caught: + pending.check_ready("x") + assert caught.value.code == PENDING_LAPSED + + +class TestFinish: + """Finishing drops the reset and is idempotent.""" + + def test_finish_clears(self, pending: _ctl_pending.PendingResets) -> None: + armed = pending.arm() + pending.finish(armed.pending_id) + with pytest.raises(CtlError): + pending.beat(armed.pending_id) + + def test_finish_other_id_keeps_current( + self, pending: _ctl_pending.PendingResets + ) -> None: + armed = pending.arm() + pending.finish("other") + assert pending.beat(armed.pending_id).pending_id == armed.pending_id + + def test_finish_with_nothing_armed( + self, pending: _ctl_pending.PendingResets + ) -> None: + pending.finish("x") + + def test_default_sources_are_usable(self) -> None: + resets = _ctl_pending.PendingResets() + assert resets.arm().ready_at > resets.arm().armed_at diff --git a/steam_backlog_enforcer/tests/test_ctl_protocol.py b/steam_backlog_enforcer/tests/test_ctl_protocol.py new file mode 100644 index 0000000..f65baef --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_protocol.py @@ -0,0 +1,174 @@ +"""Tests for the control-socket wire format and the server-side wire helpers. + +Covers ``_ctl_protocol`` (line codec, error type, responses), ``_ctl_types`` +(frozen result types) and ``_ctl_wire`` (peer uid, liveness probe, bounded line +read, request parsing). Sockets are ``socketpair``s or live under a short +temporary directory; nothing touches the real ``/run`` socket. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +import socket +import tempfile +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _ctl_protocol, _ctl_wire +from steam_backlog_enforcer._ctl_types import BlockResult, DaemonInfo + +if TYPE_CHECKING: + from collections.abc import Iterator + + +@pytest.fixture +def short_dir() -> Iterator[Path]: + """A short-path temp dir: AF_UNIX paths are capped near 108 bytes.""" + with tempfile.TemporaryDirectory(prefix="ctl") as name: + yield Path(name) + + +class TestCodec: + """Line encoding and the two response shapes.""" + + def test_roundtrip(self) -> None: + raw = _ctl_protocol.encode_line({"op": "ping", "args": {}}) + assert raw.endswith(b"\n") + assert _ctl_protocol.decode_line(raw) == {"op": "ping", "args": {}} + + def test_decode_rejects_garbage(self) -> None: + with pytest.raises(ValueError, match="Expecting"): + _ctl_protocol.decode_line(b"{nope") + + def test_ok_response(self) -> None: + body = json.loads(_ctl_protocol.ok_response({"a": 1})) + assert body == {"ok": True, "data": {"a": 1}} + + def test_error_response_without_data(self) -> None: + err = _ctl_protocol.CtlError(_ctl_protocol.LOCKED, "nope") + body = json.loads(_ctl_protocol.error_response(err)) + assert body == {"ok": False, "error": "locked", "message": "nope"} + + def test_error_response_with_data(self) -> None: + err = _ctl_protocol.CtlError(_ctl_protocol.BUSY, "wait", {"retry": 2}) + body = json.loads(_ctl_protocol.error_response(err)) + assert body["data"] == {"retry": 2} + + def test_ctl_error_attributes(self) -> None: + err = _ctl_protocol.CtlError("code", "msg") + assert (err.code, err.message, err.data) == ("code", "msg", {}) + assert str(err) == "code: msg" + + +class TestTypes: + """The result types are frozen value objects.""" + + def test_hashable_because_frozen(self) -> None: + assert hash(DaemonInfo("running", "t", 1, None)) is not None + + def test_equality(self) -> None: + assert BlockResult(3, None) == BlockResult(3, None) + + +class TestPeerUid: + """SO_PEERCRED reports the connecting process's real uid.""" + + def test_reports_own_uid(self) -> None: + left, right = socket.socketpair() + try: + assert _ctl_wire.peer_uid(left) == os.getuid() + finally: + left.close() + right.close() + + +class TestSocketIsLive: + """Probing a path for an accepting listener.""" + + def test_missing_path(self, short_dir: Path) -> None: + assert _ctl_wire.socket_is_live(short_dir / "none.sock") is False + + def test_listening_path(self, short_dir: Path) -> None: + path = short_dir / "s.sock" + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + server.bind(str(path)) + server.listen(1) + assert _ctl_wire.socket_is_live(path) is True + finally: + server.close() + + +class TestReadLine: + """Bounded, deadline-limited request reading.""" + + def test_reads_first_line(self) -> None: + left, right = socket.socketpair() + with left, right: + right.sendall(b'{"op":"ping"}\nextra') + assert _ctl_wire.read_line(left) == b'{"op":"ping"}' + + def test_eof_without_newline_returns_data(self) -> None: + left, right = socket.socketpair() + with left, right: + right.sendall(b"abc") + right.shutdown(socket.SHUT_WR) + assert _ctl_wire.read_line(left) == b"abc" + + def test_immediate_eof_returns_none(self) -> None: + left, right = socket.socketpair() + with left, right: + right.shutdown(socket.SHUT_WR) + assert _ctl_wire.read_line(left) is None + + def test_oversize_is_truncated_past_the_cap(self) -> None: + left, right = socket.socketpair() + with left, right: + right.sendall(b"x" * (_ctl_protocol.MAX_REQUEST_BYTES + 100)) + line = _ctl_wire.read_line(left) + assert line is not None + assert len(line) == _ctl_protocol.MAX_REQUEST_BYTES + 1 + + def test_stalled_client_times_out(self) -> None: + left, right = socket.socketpair() + with ( + left, + right, + patch.object(_ctl_wire, "READ_TIMEOUT_SECONDS", 0.05), + ): + assert _ctl_wire.read_line(left) is None + + def test_deadline_already_passed(self) -> None: + left, right = socket.socketpair() + with left, right, patch.object(_ctl_wire, "READ_TIMEOUT_SECONDS", -1.0): + assert _ctl_wire.read_line(left) is None + + +class TestParseRequest: + """Validation of a decoded request.""" + + def test_valid_with_args(self) -> None: + assert _ctl_wire.parse_request(b'{"op":"x","args":{"a":1}}') == ( + "x", + {"a": 1}, + ) + + def test_args_default_to_empty(self) -> None: + assert _ctl_wire.parse_request(b'{"op":"x"}') == ("x", {}) + + @pytest.mark.parametrize( + "line", + [b"{bad", b"\xff\xfe", b"[1]", b'{"op":5}', b'{"args":{}}'], + ) + def test_rejects_malformed(self, line: bytes) -> None: + with pytest.raises(_ctl_protocol.CtlError) as caught: + _ctl_wire.parse_request(line) + assert caught.value.code == _ctl_protocol.INVALID_PARAMS + + def test_rejects_non_object_args(self) -> None: + with pytest.raises(_ctl_protocol.CtlError, match="args must be"): + _ctl_wire.parse_request(b'{"op":"x","args":[1]}') diff --git a/steam_backlog_enforcer/tests/test_ctl_reset_ops.py b/steam_backlog_enforcer/tests/test_ctl_reset_ops.py new file mode 100644 index 0000000..cbaee0f --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_reset_ops.py @@ -0,0 +1,151 @@ +"""Tests for the gaming-reset ops in ``_ctl_reset``. + +The reset core and the lock gate are mocked; the countdown runs on a fake +clock, so nothing sleeps and no state file is written. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _ctl_reset +from steam_backlog_enforcer._ctl_pending import PendingResets +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer._gaming_reset import ResetOutcome +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx + +if TYPE_CHECKING: + from steam_backlog_enforcer._ctl_context import CtlContext + +_PHRASE = "reset today's gaming budget" + + +class _Clock: + """A settable monotonic clock.""" + + def __init__(self) -> None: + self.now = 0.0 + + def __call__(self) -> float: + return self.now + + +def _ctx_with_clock() -> tuple[CtlContext, _Clock]: + clock = _Clock() + ctx = make_ctx() + ctx.pending = PendingResets(clock=clock) + return ctx, clock + + +def _outcome() -> ResetOutcome: + return ResetOutcome("2026-01-01", 123.4567, was_blocked=True, released=[Path("/m")]) + + +class TestArmHeartbeatCancel: + """The cheap phases of the two-phase reset.""" + + def test_arm_returns_view(self) -> None: + ctx = make_ctx() + with patch.object(_ctl_reset, "check_locks") as locks: + view = _ctl_reset.op_arm(ctx, {"phrase": _PHRASE}) + locks.assert_called_once_with("gaming-reset") + assert view["phrase"] == _PHRASE + assert view["heartbeat_interval_seconds"] == 10 + assert view["lapse_after_seconds"] == 15 + assert set(view) >= {"pending_id", "armed_at", "ready_at"} + + def test_arm_wrong_phrase(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_reset.op_arm(make_ctx(), {"phrase": "no"}) + assert caught.value.code == "wrong_phrase" + + def test_heartbeat(self) -> None: + ctx = make_ctx() + pending_id = ctx.pending.arm().pending_id + view = _ctl_reset.op_heartbeat(ctx, {"pending_id": pending_id}) + assert view["pending_id"] == pending_id + + def test_heartbeat_needs_id(self) -> None: + with pytest.raises(CtlError) as caught: + _ctl_reset.op_heartbeat(make_ctx(), {}) + assert caught.value.code == "invalid_params" + + def test_cancel_is_idempotent(self) -> None: + ctx = make_ctx() + pending_id = ctx.pending.arm().pending_id + for _ in range(2): + result = _ctl_reset.op_cancel(ctx, {"pending_id": pending_id}) + assert result == {"cancelled": True} + with pytest.raises(CtlError): + ctx.pending.beat(pending_id) + + +class TestCommit: + """The commit phase re-checks everything under the tick lock.""" + + @staticmethod + def _ready() -> tuple[CtlContext, str]: + ctx, clock = _ctx_with_clock() + pending_id = ctx.pending.arm().pending_id + for _ in range(31): + clock.now += 10 + ctx.pending.beat(pending_id) + return ctx, pending_id + + def test_commits_and_clears(self) -> None: + ctx, pending_id = self._ready() + with ( + patch.object(_ctl_reset, "check_locks"), + patch.object(_ctl_reset, "reset_today", return_value=_outcome()) as reset, + ): + data = _ctl_reset.op_commit( + ctx, {"pending_id": pending_id, "phrase": _PHRASE} + ) + reset.assert_called_once_with(source="ctl") + assert data == { + "day_key": "2026-01-01", + "seconds_before": 123.457, + "was_blocked": True, + "released": ["/m"], + } + assert not ctx.tick_lock.locked() + with pytest.raises(CtlError): + ctx.pending.beat(pending_id) + + def test_too_early(self) -> None: + ctx = make_ctx() + pending_id = ctx.pending.arm().pending_id + with pytest.raises(CtlError) as caught: + _ctl_reset.op_commit(ctx, {"pending_id": pending_id, "phrase": _PHRASE}) + assert caught.value.code == "countdown_running" + + def test_wrong_phrase(self) -> None: + ctx, pending_id = self._ready() + with pytest.raises(CtlError) as caught: + _ctl_reset.op_commit(ctx, {"pending_id": pending_id, "phrase": "no"}) + assert caught.value.code == "wrong_phrase" + + def test_os_error_is_op_failed_and_keeps_pending(self) -> None: + ctx, pending_id = self._ready() + with ( + patch.object(_ctl_reset, "check_locks"), + patch.object(_ctl_reset, "reset_today", side_effect=OSError("ro")), + pytest.raises(CtlError) as caught, + ): + _ctl_reset.op_commit(ctx, {"pending_id": pending_id, "phrase": _PHRASE}) + assert caught.value.code == "op_failed" + assert ctx.pending.check_ready(pending_id).pending_id == pending_id + + def test_locked(self) -> None: + ctx, pending_id = self._ready() + lock = CtlError("locked", "total block") + with ( + patch.object(_ctl_reset, "check_locks", side_effect=lock), + pytest.raises(CtlError) as caught, + ): + _ctl_reset.op_commit(ctx, {"pending_id": pending_id, "phrase": _PHRASE}) + assert caught.value.code == "locked" diff --git a/steam_backlog_enforcer/tests/test_ctl_restart.py b/steam_backlog_enforcer/tests/test_ctl_restart.py new file mode 100644 index 0000000..5a3a985 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_restart.py @@ -0,0 +1,159 @@ +"""Tests for the restart op, its persisted rate limit and the billed-gap marker. + +``RESTART_STATE_FILE`` / ``RESTART_EXIT_FILE`` are redirected to tmp_path by +the conftest's control-plane fixture; they are reached through the module +attribute so the redirect applies. +""" + +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _ctl_gap, _ctl_restart +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx + +if TYPE_CHECKING: + from pathlib import Path + + +def _write_last(value: object) -> None: + path = _ctl_restart.RESTART_STATE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"last_restart_at": value})) + + +class TestRateLimitState: + """Reading the persisted last-restart time.""" + + def test_absent_means_allowed(self) -> None: + assert _ctl_restart.seconds_until_available() == 0.0 + assert _ctl_restart.restart_available_at() is None + + @pytest.mark.parametrize( + "text", ["not json", "{}", "[1]", '{"last_restart_at":"x"}'] + ) + def test_unusable_file_means_allowed(self, text: str) -> None: + path = _ctl_restart.RESTART_STATE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + assert _ctl_restart.seconds_until_available() == 0.0 + + def test_recent_restart_waits(self) -> None: + _write_last(time.time() - 100) + wait = _ctl_restart.seconds_until_available() + assert 495 < wait <= 500 + assert _ctl_restart.restart_available_at() is not None + + def test_future_timestamp_is_capped_at_interval(self) -> None: + _write_last(time.time() + 99999) + wait = _ctl_restart.seconds_until_available() + assert wait == pytest.approx(_ctl_restart.RESTART_INTERVAL_SECONDS, abs=1) + + def test_old_restart_allowed(self) -> None: + _write_last(time.time() - 10_000) + assert _ctl_restart.seconds_until_available() == 0.0 + + +class TestOpRestart: + """The restart op.""" + + def test_unsupervised_is_refused(self) -> None: + ctx = make_ctx(supervised=False) + with pytest.raises(CtlError) as caught: + _ctl_restart.op_restart(ctx, {}) + assert caught.value.code == "unsupported" + assert not ctx.restart_event.is_set() + + def test_accepts_and_persists(self) -> None: + ctx = make_ctx() + data = _ctl_restart.op_restart(ctx, {}) + assert ctx.restart_event.is_set() + assert data["restarting"] is True + assert data["restart_available_at"] is not None + saved = json.loads(_ctl_restart.RESTART_STATE_FILE.read_text()) + assert saved["last_restart_at"] == pytest.approx(time.time(), abs=5) + assert _ctl_restart.RESTART_STATE_FILE.stat().st_mode & 0o777 == 0o600 + + def test_second_restart_is_rate_limited(self) -> None: + _ctl_restart.op_restart(make_ctx(), {}) + ctx = make_ctx() + with pytest.raises(CtlError) as caught: + _ctl_restart.op_restart(ctx, {}) + assert caught.value.code == "rate_limited" + assert caught.value.data["retry_after_seconds"] > 0 + assert not ctx.restart_event.is_set() + + def test_unwritable_limit_fails_closed(self) -> None: + ctx = make_ctx() + with ( + patch.object(_ctl_restart, "_atomic_write", side_effect=OSError("ro")), + pytest.raises(CtlError) as caught, + ): + _ctl_restart.op_restart(ctx, {}) + assert caught.value.code == "op_failed" + assert not ctx.restart_event.is_set() + + +class TestRestartGap: + """The exit marker and its settlement by the next daemon.""" + + def test_record_exit_writes_marker(self) -> None: + _ctl_gap.record_exit() + saved = json.loads(_ctl_gap.RESTART_EXIT_FILE.read_text()) + assert saved["exited_at"] == pytest.approx(time.time(), abs=5) + + def test_record_exit_never_raises(self) -> None: + with patch.object(_ctl_gap, "_atomic_write", side_effect=OSError("ro")): + _ctl_gap.record_exit() + + def _marker(self, text: str) -> Path: + path = _ctl_gap.RESTART_EXIT_FILE + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + return path + + @pytest.mark.parametrize("text", ["junk", "{}", '{"exited_at":"x"}']) + def test_unusable_marker_is_dropped(self, text: str) -> None: + path = self._marker(text) + assert _ctl_gap._take_exit_time() is None + assert not path.exists() + + def test_missing_marker(self) -> None: + assert _ctl_gap._take_exit_time() is None + + def _settle(self, *, demo: bool = False) -> MagicMock: + with patch.object(_ctl_gap, "playtime_tick") as tick: + _ctl_gap.settle_restart_gap( + MagicMock(), MagicMock(), base_interval=3.0, demo=demo + ) + return tick + + def test_bills_the_gap_once(self) -> None: + path = self._marker(json.dumps({"exited_at": time.time() - 20})) + tick = self._settle() + interval = tick.call_args.kwargs["interval"] + assert interval == pytest.approx(11, abs=0.5) + assert not path.exists() + + def test_short_gap_keeps_base_interval(self) -> None: + self._marker(json.dumps({"exited_at": time.time() - 0.5})) + assert self._settle().call_args.kwargs["interval"] == 3.0 + + @pytest.mark.parametrize("age", [-50, 301]) + def test_out_of_window_is_not_billed(self, age: float) -> None: + self._marker(json.dumps({"exited_at": time.time() - age})) + self._settle().assert_not_called() + + def test_no_marker_bills_nothing(self) -> None: + self._settle().assert_not_called() + + def test_demo_never_bills_but_spends_marker(self) -> None: + path = self._marker(json.dumps({"exited_at": time.time() - 20})) + self._settle(demo=True).assert_not_called() + assert not path.exists() diff --git a/steam_backlog_enforcer/tests/test_ctl_server.py b/steam_backlog_enforcer/tests/test_ctl_server.py new file mode 100644 index 0000000..8721688 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_server.py @@ -0,0 +1,150 @@ +"""Tests for ``CtlServer`` over a real Unix socket in a temp directory. + +``os.chown`` (the server chowns its socket to root) is mocked: the suite runs +unprivileged. Clients are the real ``_ctl_client.call`` against the server. +""" + +from __future__ import annotations + +import json +import os +import socket +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _ctl_client, _ctl_server +from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx, short_socket_dir + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + +@pytest.fixture +def sock_path() -> Iterator[Path]: + with short_socket_dir() as directory: + yield directory / "ctl.sock" + + +@pytest.fixture +def server(sock_path: Path) -> Iterator[_ctl_server.CtlServer]: + srv = _ctl_server.CtlServer( + make_ctx(), desktop_uid=os.getuid(), desktop_gid=os.getgid(), path=sock_path + ) + with patch.object(_ctl_server.os, "chown"): + assert srv.start() is True + yield srv + srv.stop() + + +class TestLifecycle: + """Binding, refusing a second server, stopping.""" + + def test_serves_ping_and_stop_removes_socket( + self, server: _ctl_server.CtlServer, sock_path: Path + ) -> None: + assert _ctl_client.call("ping", path=sock_path) == {"pong": True} + assert sock_path.stat().st_mode & 0o777 == _ctl_server.SOCKET_MODE + server.stop() + assert not sock_path.exists() + + def test_does_not_take_over_a_live_socket( + self, server: _ctl_server.CtlServer, sock_path: Path + ) -> None: + second = _ctl_server.CtlServer( + make_ctx(), desktop_uid=os.getuid(), desktop_gid=os.getgid(), path=sock_path + ) + assert second.start() is False + assert _ctl_client.call("ping", path=sock_path) == {"pong": True} + + def test_replaces_a_stale_socket_file(self, sock_path: Path) -> None: + sock_path.write_text("stale") + srv = _ctl_server.CtlServer( + make_ctx(), desktop_uid=os.getuid(), desktop_gid=os.getgid(), path=sock_path + ) + with patch.object(_ctl_server.os, "chown"): + assert srv.start() is True + srv.stop() + + def test_bind_failure_returns_false(self, sock_path: Path) -> None: + blocker = sock_path.parent / "file" + blocker.write_text("x") + srv = _ctl_server.CtlServer( + make_ctx(), + desktop_uid=os.getuid(), + desktop_gid=os.getgid(), + path=blocker / "ctl.sock", + ) + assert srv.start() is False + + def test_stop_before_start(self, sock_path: Path) -> None: + srv = _ctl_server.CtlServer( + make_ctx(), desktop_uid=1, desktop_gid=1, path=sock_path + ) + srv.stop() + + +class TestRequests: + """End-to-end dispatch through the real socket.""" + + def test_unknown_op(self, server: _ctl_server.CtlServer, sock_path: Path) -> None: + with pytest.raises(CtlError) as caught: + _ctl_client.call("rm_rf", path=sock_path) + assert caught.value.code == "unknown_command" + + def test_crashing_op_is_internal_error( + self, server: _ctl_server.CtlServer, sock_path: Path + ) -> None: + boom = MagicMock(side_effect=ValueError("bad")) + with ( + patch.dict(_ctl_server.OPS, {"boom": boom}), + pytest.raises(CtlError) as caught, + ): + _ctl_client.call("boom", path=sock_path) + assert caught.value.code == "op_failed" + + def test_refused_op_carries_error_data( + self, server: _ctl_server.CtlServer, sock_path: Path + ) -> None: + refuse = MagicMock(side_effect=CtlError("busy", "wait", {"n": 1})) + with ( + patch.dict(_ctl_server.OPS, {"r": refuse}), + pytest.raises(CtlError) as caught, + ): + _ctl_client.call("r", path=sock_path) + assert (caught.value.code, caught.value.data) == ("busy", {"n": 1}) + + def test_oversize_line(self, server: _ctl_server.CtlServer) -> None: + with pytest.raises(CtlError) as caught: + server._run(b"x" * (_ctl_server.MAX_REQUEST_BYTES + 1), 0) + assert caught.value.code == "invalid_params" + + def test_malformed_request_over_socket( + self, server: _ctl_server.CtlServer, sock_path: Path + ) -> None: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as raw: + raw.settimeout(5) + raw.connect(str(sock_path)) + raw.sendall(b"not json\n") + reply = json.loads(raw.makefile().readline()) + assert reply["ok"] is False + assert reply["error"] == "invalid_params" + + def test_silent_client_gets_no_reply( + self, server: _ctl_server.CtlServer, sock_path: Path + ) -> None: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as raw: + raw.settimeout(5) + raw.connect(str(sock_path)) + raw.shutdown(socket.SHUT_WR) + assert raw.recv(10) == b"" + + def test_unreachable_after_stop( + self, server: _ctl_server.CtlServer, sock_path: Path + ) -> None: + server.stop() + with pytest.raises(DaemonUnreachableError): + _ctl_client.call("ping", path=sock_path) diff --git a/steam_backlog_enforcer/tests/test_ctl_server_internals.py b/steam_backlog_enforcer/tests/test_ctl_server_internals.py new file mode 100644 index 0000000..7a5cf07 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_ctl_server_internals.py @@ -0,0 +1,186 @@ +"""Tests for ``CtlServer``'s accept loop, admission and handler internals. + +These drive the private methods directly with mock sockets, to reach the +failure branches (accept errors, refused uids, exhausted slots, a handler that +blows up) that a well-behaved client never triggers. +""" + +from __future__ import annotations + +import os +import threading +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _ctl_server +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx + + +def _server() -> _ctl_server.CtlServer: + return _ctl_server.CtlServer( + make_ctx(), desktop_uid=os.getuid() + 1, desktop_gid=1, path=MagicMock() + ) + + +class TestServeLoop: + """The accept loop survives everything but being stopped.""" + + def test_no_socket_returns(self) -> None: + _server()._serve() + + def test_timeout_then_stop(self) -> None: + srv = _server() + sock = MagicMock() + + def accept() -> None: + srv._stop.set() + raise TimeoutError + + sock.accept.side_effect = accept + srv._sock = sock + srv._serve() + sock.accept.assert_called_once() + + def test_oserror_while_stopping_returns_quietly(self) -> None: + srv = _server() + sock = MagicMock() + + def accept() -> None: + srv._stop.set() + msg = "closed" + raise OSError(msg) + + sock.accept.side_effect = accept + srv._sock = sock + with patch.object(_ctl_server.time, "sleep") as sleep: + srv._serve() + sleep.assert_not_called() + + def test_oserror_while_running_backs_off(self) -> None: + srv = _server() + sock = MagicMock() + sock.accept.side_effect = [OSError("emfile"), TimeoutError] + srv._sock = sock + + def stop(_seconds: float) -> None: + srv._stop.set() + + with patch.object(_ctl_server.time, "sleep", side_effect=stop) as sleep: + srv._serve() + sleep.assert_called_once() + + def test_accepted_connection_is_admitted(self) -> None: + srv = _server() + conn = MagicMock() + sock = MagicMock() + sock.accept.return_value = (conn, None) + srv._sock = sock + + def admit(_conn: object) -> None: + srv._stop.set() + + with patch.object(srv, "_admit", side_effect=admit) as admitted: + srv._serve() + admitted.assert_called_once_with(conn) + + +class TestAdmit: + """Authentication and the connection cap.""" + + def test_peercred_failure_closes(self) -> None: + conn = MagicMock() + with patch.object(_ctl_server, "peer_uid", side_effect=OSError("no")): + _server()._admit(conn) + conn.close.assert_called_once() + + def test_foreign_uid_is_closed_unanswered(self) -> None: + conn = MagicMock() + with patch.object(_ctl_server, "peer_uid", return_value=31337): + _server()._admit(conn) + conn.close.assert_called_once() + conn.sendall.assert_not_called() + + def test_no_free_slot_drops(self) -> None: + srv = _server() + srv._slots = threading.BoundedSemaphore(1) + srv._slots.acquire() + conn = MagicMock() + with patch.object(_ctl_server, "peer_uid", return_value=0): + srv._admit(conn) + conn.close.assert_called_once() + + def test_allowed_uid_gets_a_worker(self) -> None: + srv = _server() + conn = MagicMock() + with ( + patch.object(_ctl_server, "peer_uid", return_value=0), + patch.object(_ctl_server.threading, "Thread") as thread, + ): + srv._admit(conn) + thread.return_value.start.assert_called_once() + assert thread.call_args.kwargs["args"] == (conn, 0) + + +class TestHandle: + """A connection handler always releases its slot and socket.""" + + def test_writes_dispatch_result(self) -> None: + srv = _server() + conn = MagicMock() + with ( + patch.object(_ctl_server, "read_line", return_value=b"line"), + patch.object(srv, "_dispatch", return_value=b"out") as dispatch, + ): + srv._slots.acquire() + srv._handle(conn, 7) + dispatch.assert_called_once_with(b"line", 7) + conn.sendall.assert_called_once_with(b"out") + conn.close.assert_called_once() + + def test_silent_client_writes_nothing(self) -> None: + srv = _server() + conn = MagicMock() + srv._slots.acquire() + with patch.object(_ctl_server, "read_line", return_value=None): + srv._handle(conn, 7) + conn.sendall.assert_not_called() + conn.close.assert_called_once() + + def test_write_failure_is_swallowed(self) -> None: + srv = _server() + conn = MagicMock() + conn.sendall.side_effect = BrokenPipeError + srv._slots.acquire() + with ( + patch.object(_ctl_server, "read_line", return_value=b"x"), + patch.object(srv, "_dispatch", return_value=b"out"), + ): + srv._handle(conn, 7) + conn.close.assert_called_once() + # The slot came back: the semaphore can be filled to its cap again. + for _ in range(_ctl_server.MAX_CONNECTIONS): + assert srv._slots.acquire(blocking=False) + + +class TestDispatch: + """_dispatch never raises.""" + + def test_ok(self) -> None: + srv = _server() + assert b'"ok":true' in srv._dispatch(b'{"op":"ping"}', 0) + + def test_ctl_error(self) -> None: + srv = _server() + with patch.object(srv, "_run", side_effect=CtlError("locked", "x")): + assert b'"error":"locked"' in srv._dispatch(b"", 0) + + def test_unexpected_error(self) -> None: + srv = _server() + with patch.object(srv, "_run", side_effect=RuntimeError("x")): + assert b'"error":"op_failed"' in srv._dispatch(b"", 0) + + def test_run_rejects_unknown(self) -> None: + with pytest.raises(CtlError, match="unknown op"): + _server()._run(b'{"op":"zzz"}', 0) diff --git a/steam_backlog_enforcer/tests/test_do_enforce.py b/steam_backlog_enforcer/tests/test_do_enforce.py index e18d770..1798965 100644 --- a/steam_backlog_enforcer/tests/test_do_enforce.py +++ b/steam_backlog_enforcer/tests/test_do_enforce.py @@ -5,8 +5,9 @@ Split to keep every test file under the 250-line cap. from __future__ import annotations +import threading from typing import TYPE_CHECKING -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -180,3 +181,28 @@ class TestStateReload: do_enforce(Config(), state) assert state.manual_picks == fresh.manual_picks assert state.finished_app_ids == [7] + + +class TestRestartRequested: + """A control-socket ``restart`` op ends the loop cleanly.""" + + def test_flush_then_exit_without_waiting(self) -> None: + state = State(current_app_id=1, current_game_name="G") + control = MagicMock() + control.tick_lock = threading.Lock() + control.flush_for_restart.return_value = True + with ( + patch(f"{PKG}.steam_is_installed", return_value=True), + patch(f"{PKG}._enforce_setup"), + patch(f"{PKG}._echo") as mock_echo, + patch(f"{PKG}.start_control", return_value=control), + patch(f"{PKG}.settle_restart_gap"), + patch.object(State, "load", return_value=state), + patch(f"{PKG}._enforce_loop_iteration") as mock_iter, + patch(f"{PKG}.open_file_wake") as wake, + ): + do_enforce(Config(), state) + mock_iter.assert_called_once() + control.flush_for_restart.assert_called_once() + wake.return_value.wait.assert_not_called() + assert any("Restart requested" in str(c) for c in mock_echo.call_args_list) diff --git a/steam_backlog_enforcer/tests/test_enforce_loop.py b/steam_backlog_enforcer/tests/test_enforce_loop.py index b70f812..95013df 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop.py @@ -148,7 +148,7 @@ class TestGetAllOwnedAppIds: result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) assert result == [10, 20] - mock_save.assert_called_once_with("i", [10, 20]) + mock_save.assert_called_once_with("i", [10, 20], [{"appid": 10}, {"appid": 20}]) class TestOwnedIdsCacheHelpers: diff --git a/steam_backlog_enforcer/tests/test_enforce_loop_part2.py b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py index de59fc5..4e60b60 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop_part2.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py @@ -71,7 +71,7 @@ class TestEnforceLoopIteration: ) state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{PKG}._guard_installed_games", return_value=1), + patch(f"{PKG}.guard_installed_games", return_value=1), patch(f"{PKG}._echo"), patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): @@ -84,7 +84,7 @@ class TestEnforceLoopIteration: ) state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{PKG}._guard_installed_games", return_value=0), + patch(f"{PKG}.guard_installed_games", return_value=0), patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), ): _enforce_loop_iteration(config, state, session=fake_session()) @@ -103,7 +103,7 @@ class TestEnforceLoopIteration: with ( patch(f"{PKG}.steam_is_installed", return_value=False), patch(f"{PKG}.enforce_allowed_game") as mock_enforce, - patch(f"{PKG}._guard_installed_games") as mock_guard, + patch(f"{PKG}.guard_installed_games") as mock_guard, patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed, patch(f"{ENFORCE_STEPS_PKG}.install_game") as mock_install, ): @@ -135,7 +135,7 @@ class TestEnforceLoopIteration: state = State(current_app_id=None) with ( patch(f"{PKG}.enforce_allowed_game") as mock_enforce, - patch(f"{PKG}._guard_installed_games") as mock_guard, + patch(f"{PKG}.guard_installed_games") as mock_guard, patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed, ): _enforce_loop_iteration(config, state, session=fake_session()) diff --git a/steam_backlog_enforcer/tests/test_enforce_loop_part3.py b/steam_backlog_enforcer/tests/test_enforce_loop_part3.py index aa80ff9..aaad4fb 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop_part3.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop_part3.py @@ -25,7 +25,7 @@ class TestEnforceLoopIterationTotalBlock: patch(f"{PKG}.is_total_block_active", return_value=True), patch(f"{PKG}.enforce_total_block_tick") as mock_tick, patch(f"{PKG}.enforce_allowed_game") as mock_enforce, - patch(f"{PKG}._guard_installed_games") as mock_guard, + patch(f"{PKG}.guard_installed_games") as mock_guard, patch(f"{ENFORCE_STEPS_PKG}.is_game_installed") as mock_installed, ): _enforce_loop_iteration(config, state, session=fake_session()) diff --git a/steam_backlog_enforcer/tests/test_enforce_steps.py b/steam_backlog_enforcer/tests/test_enforce_steps.py index b799146..3732e69 100644 --- a/steam_backlog_enforcer/tests/test_enforce_steps.py +++ b/steam_backlog_enforcer/tests/test_enforce_steps.py @@ -7,7 +7,7 @@ from __future__ import annotations from unittest.mock import patch -from steam_backlog_enforcer._enforce_loop import _guard_installed_games +from steam_backlog_enforcer._enforce_guards import guard_installed_games from steam_backlog_enforcer._enforce_steps import ( _enforce_auto_install, _enforce_hide_games, @@ -17,52 +17,55 @@ from steam_backlog_enforcer.config import Config, State PKG = "steam_backlog_enforcer._enforce_loop" ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps" +GUARDS_PKG = "steam_backlog_enforcer._enforce_guards" OWNED_APPS_CACHE_PKG = "steam_backlog_enforcer._owned_apps_cache" class TestGuardInstalledGames: - """Tests for _guard_installed_games.""" + """Tests for guard_installed_games.""" def test_removes_unauthorized(self) -> None: with ( patch( - f"{PKG}.get_installed_games", + f"{GUARDS_PKG}.get_installed_games", return_value=[(999, "Bad Game")], ), - patch(f"{PKG}.uninstall_game", return_value=True), - patch(f"{ENFORCE_STEPS_PKG}.send_notification"), + patch(f"{GUARDS_PKG}.uninstall_game", return_value=True), + patch(f"{GUARDS_PKG}.send_notification"), ): - assert _guard_installed_games({440}) == 1 + assert guard_installed_games({440}) == 1 def test_skips_allowed(self) -> None: with patch( - f"{PKG}.get_installed_games", + f"{GUARDS_PKG}.get_installed_games", return_value=[(440, "TF2")], ): - assert _guard_installed_games({440}) == 0 + assert guard_installed_games({440}) == 0 def test_skips_protected(self) -> None: with ( patch( - f"{PKG}.get_installed_games", + f"{GUARDS_PKG}.get_installed_games", return_value=[(228980, "Runtime")], ), - patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980), + patch( + f"{GUARDS_PKG}.is_protected_app", side_effect=lambda aid: aid == 228980 + ), ): - assert _guard_installed_games({440}) == 0 + assert guard_installed_games({440}) == 0 def test_uninstall_fails(self) -> None: with ( patch( - f"{PKG}.get_installed_games", + f"{GUARDS_PKG}.get_installed_games", return_value=[(999, "Bad")], ), - patch(f"{PKG}.uninstall_game", return_value=False), + patch(f"{GUARDS_PKG}.uninstall_game", return_value=False), ): - assert _guard_installed_games({440}) == 0 + assert guard_installed_games({440}) == 0 def test_allowed_none_skips(self) -> None: - assert _guard_installed_games(set()) == 0 + assert guard_installed_games(set()) == 0 class TestEnforceSetup: diff --git a/steam_backlog_enforcer/tests/test_gaming_reset.py b/steam_backlog_enforcer/tests/test_gaming_reset.py new file mode 100644 index 0000000..8d75892 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_gaming_reset.py @@ -0,0 +1,103 @@ +"""Tests for ``_gaming_reset.reset_today``, the core shared by CLI and socket. + +State, history and the budget audit log are the conftest-redirected tmp_path +files; the mount release is mocked (it would unmount the host's Steam). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +from steam_backlog_enforcer import _gaming_reset, _playtime_log +from steam_backlog_enforcer._gaming_days import gaming_day_key +from steam_backlog_enforcer._playtime_state import ( + PlaytimeState, + load_state, + save_state, +) + + +def _today() -> str: + return gaming_day_key(_gaming_reset.datetime.now(_gaming_reset.UTC).astimezone()) + + +def _audit_records() -> list[dict[str, object]]: + path = _playtime_log.budget_log_path(demo=False) + return [json.loads(line) for line in path.read_text().splitlines()] + + +def _spent_state(day_key: str) -> PlaytimeState: + return PlaytimeState( + day_key=day_key, + seconds=900.0, + blocked_at=5.0, + per_game={"1": 600.0, "2": 300.0}, + last_credited_key="1", + warned_seconds=[600], + budget_seconds=1800.0, + carry={"2999-01-01": 120.0}, + ) + + +class TestResetToday: + """Zero the counter, release mounts, keep earned time, leave a record.""" + + def test_zeroes_state_but_keeps_earned_time(self) -> None: + save_state(_spent_state(_today()), demo=False) + with patch.object( + _gaming_reset, "release_block", return_value=[Path("/m/steam")] + ): + outcome = _gaming_reset.reset_today(source="ctl") + assert outcome.day_key == _today() + assert outcome.seconds_before == 900.0 + assert outcome.was_blocked is True + assert outcome.released == [Path("/m/steam")] + stored = load_state(demo=False) + assert stored is not None + assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {}) + assert (stored.last_credited_key, stored.warned_seconds) == ("", []) + assert (stored.budget_seconds, stored.carry) == (1800.0, {"2999-01-01": 120.0}) + assert stored.last_tick_at > 0 + + def test_audit_record_has_the_before_picture(self) -> None: + save_state(_spent_state(_today()), demo=False) + with patch.object(_gaming_reset, "release_block", return_value=[Path("/m")]): + _gaming_reset.reset_today(source="cli") + record = _audit_records()[-1] + assert record["kind"] == "gaming_reset" + assert record["source"] == "cli" + assert record["billed_seconds_before"] == 900.0 + assert record["billed_seconds_after"] == 0.0 + assert record["per_game_before"] == {"1": 600.0, "2": 300.0} + assert record["released"] == ["/m"] + + def test_stale_day_is_rolled_over_first(self) -> None: + save_state(_spent_state("2000-01-01"), demo=False) + with patch.object(_gaming_reset, "release_block", return_value=[]): + outcome = _gaming_reset.reset_today(source="ctl") + assert outcome.seconds_before == 0.0 + assert outcome.was_blocked is False + stored = load_state(demo=False) + assert stored is not None + assert stored.day_key == _today() + + def test_no_stored_state(self) -> None: + with patch.object(_gaming_reset, "release_block", return_value=[]): + outcome = _gaming_reset.reset_today(source="ctl") + assert outcome.seconds_before == 0.0 + assert outcome.released == [] + assert load_state(demo=False) is not None + + def test_history_failure_does_not_undo_the_reset(self) -> None: + save_state(_spent_state(_today()), demo=False) + with ( + patch.object(_gaming_reset, "release_block", return_value=[]), + patch.object(_gaming_reset, "record_day", side_effect=OSError("full")), + ): + outcome = _gaming_reset.reset_today(source="ctl") + assert outcome.seconds_before == 900.0 + stored = load_state(demo=False) + assert stored is not None + assert stored.seconds == 0.0 diff --git a/steam_backlog_enforcer/tests/test_hltb_progress_cb.py b/steam_backlog_enforcer/tests/test_hltb_progress_cb.py new file mode 100644 index 0000000..ec3105d --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_progress_cb.py @@ -0,0 +1,25 @@ +"""Tests for reporting_progress_cb: HLTB lookups reporting web-job progress.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from steam_backlog_enforcer._hltb_cached import reporting_progress_cb +from steam_backlog_enforcer._progress import use_progress + + +class TestReportingProgressCb: + def test_starts_a_phase_and_advances_by_the_done_count(self) -> None: + progress = MagicMock() + with use_progress(progress): + report = reporting_progress_cb("Fetching", 5, None) + report(2, 5, 1, "Doom") + progress.phase.assert_called_once_with("Fetching", 5) + progress.advance.assert_called_once_with("Doom", step=2) + + def test_forwards_to_the_callers_own_meter(self) -> None: + inner = MagicMock() + with use_progress(MagicMock()): + report = reporting_progress_cb("Fetching", 5, inner) + report(3, 5, 2, "Quake") + inner.assert_called_once_with(3, 5, 2, "Quake") diff --git a/steam_backlog_enforcer/tests/test_hltb_search_url_memo.py b/steam_backlog_enforcer/tests/test_hltb_search_url_memo.py new file mode 100644 index 0000000..0470bdc --- /dev/null +++ b/steam_backlog_enforcer/tests/test_hltb_search_url_memo.py @@ -0,0 +1,46 @@ +"""Tests for the HLTB search-endpoint memo (discovery costs ~0.8 s).""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer import _hltb_search_api as api + +_PKG = "steam_backlog_enforcer._hltb_search_api" +_FOUND = "https://howlongtobeat.com/api/search/abc" + + +class TestSearchUrlMemo: + def test_a_discovered_url_is_reused_until_it_goes_stale(self) -> None: + with ( + patch(f"{_PKG}._discover_hltb_search_url", return_value=_FOUND) as find, + patch(f"{_PKG}.time.monotonic", side_effect=[0.0, 10.0]), + ): + assert api._get_hltb_search_url() == _FOUND + assert api._get_hltb_search_url() == _FOUND + find.assert_called_once() + + def test_a_stale_memo_is_rediscovered(self) -> None: + later = api._SEARCH_URL_TTL_SECONDS + 1 + with ( + patch(f"{_PKG}._discover_hltb_search_url", return_value=_FOUND) as find, + patch(f"{_PKG}.time.monotonic", side_effect=[0.0, later]), + ): + api._get_hltb_search_url() + api._get_hltb_search_url() + assert find.call_count == 2 + + def test_the_fallback_is_never_remembered(self) -> None: + with patch( + f"{_PKG}._discover_hltb_search_url", return_value=api._DEFAULT_SEARCH_URL + ) as find: + api._get_hltb_search_url() + api._get_hltb_search_url() + assert find.call_count == 2 + + def test_forgetting_forces_a_new_discovery(self) -> None: + with patch(f"{_PKG}._discover_hltb_search_url", return_value=_FOUND) as find: + api._get_hltb_search_url() + api.forget_hltb_search_url() + api._get_hltb_search_url() + assert find.call_count == 2 diff --git a/steam_backlog_enforcer/tests/test_jobs_control.py b/steam_backlog_enforcer/tests/test_jobs_control.py new file mode 100644 index 0000000..4687dae --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_control.py @@ -0,0 +1,150 @@ +"""Tests for ``jobs._control``: answering prompts and cancelling jobs.""" + +from __future__ import annotations + +import signal +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer.jobs import _control +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._events import ( + ANSWERS_NAME, + CANCEL_NAME, + EventWriter, + append_jsonl, + read_jsonl, +) +from steam_backlog_enforcer.tests._jobs_helpers import JOB_ID, make_job + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_ALIVE = "steam_backlog_enforcer.jobs._control.pid_of" +_PID_ALIVE = "steam_backlog_enforcer.jobs._view._pid_alive" +_KILL = "steam_backlog_enforcer.jobs._control.os.kill" + + +@pytest.fixture(autouse=True) +def _job_process_is_alive() -> Iterator[None]: + """Make every fake job's pid look alive, so reading it never fails it.""" + with patch(_PID_ALIVE, return_value=True): + yield + + +def _waiting_job() -> Path: + """A job blocked on prompt ``p1``.""" + path = make_job(states=("queued", "running", "waiting_input"), pid="5\n") + EventWriter(path).emit("prompt", prompt_id="p1", kind="text", message="?") + return path + + +class TestOpenPromptId: + """Which prompt is waiting.""" + + def test_no_prompt(self) -> None: + """A job that never asked has no open prompt.""" + make_job() + assert _control._open_prompt_id(JOB_ID) is None + + def test_unanswered_prompt_is_open(self) -> None: + """The latest prompt without an answer.""" + _waiting_job() + assert _control._open_prompt_id(JOB_ID) == "p1" + + def test_answered_prompt_is_closed(self) -> None: + """An answer closes it.""" + path = _waiting_job() + append_jsonl(path / ANSWERS_NAME, {"prompt_id": "p1", "value": "x"}) + assert _control._open_prompt_id(JOB_ID) is None + + +class TestWriteAnswer: + """Delivering an answer.""" + + def test_writes_answer_line(self) -> None: + """The answer lands in answers.jsonl.""" + path = _waiting_job() + with patch(_ALIVE, return_value=5): + _control.write_answer(JOB_ID, "p1", "yes") + assert read_jsonl(path / ANSWERS_NAME) == [{"prompt_id": "p1", "value": "yes"}] + + def test_unknown_job(self) -> None: + """Unknown ids are ``not_found``.""" + with pytest.raises(JobError) as info: + _control.write_answer(JOB_ID, "p1", "yes") + assert info.value.code == "not_found" + + def test_finished_job(self) -> None: + """A finished job takes no answers.""" + make_job(states=("running", "succeeded")) + with pytest.raises(JobError) as info: + _control.write_answer(JOB_ID, "p1", "yes") + assert info.value.code == "invalid_params" + assert "already finished" in info.value.message + + def test_stale_prompt_id(self) -> None: + """Only the open prompt can be answered.""" + _waiting_job() + with patch(_ALIVE, return_value=5), pytest.raises(JobError) as info: + _control.write_answer(JOB_ID, "old", "yes") + assert info.value.code == "invalid_params" + assert "not waiting" in info.value.message + + def test_replayed_answer(self) -> None: + """A second answer to the same prompt is refused.""" + _waiting_job() + with patch(_ALIVE, return_value=5): + _control.write_answer(JOB_ID, "p1", "yes") + with pytest.raises(JobError): + _control.write_answer(JOB_ID, "p1", "no") + + +class TestCancel: + """Stopping a job.""" + + def test_waiting_job_gets_marker(self) -> None: + """Any command can be cancelled while it waits on a prompt.""" + path = make_job("reset", states=("running", "waiting_input"), pid="5\n") + with patch(_KILL) as kill: + _control.cancel(JOB_ID) + assert (path / CANCEL_NAME).exists() + kill.assert_not_called() + + def test_not_cancellable_command(self) -> None: + """A running ``done`` cannot be stopped midway.""" + make_job("done") + with pytest.raises(JobError) as info: + _control.cancel(JOB_ID) + assert info.value.code == "not_cancellable" + + def test_unknown_command_is_not_cancellable(self) -> None: + """No flags at all means not cancellable.""" + make_job("mystery") + with pytest.raises(JobError) as info: + _control.cancel(JOB_ID) + assert info.value.code == "not_cancellable" + + def test_finished_job(self) -> None: + """A cancellable but finished job is ``invalid_params``.""" + make_job("scan", states=("running", "succeeded")) + with pytest.raises(JobError) as info: + _control.cancel(JOB_ID) + assert info.value.code == "invalid_params" + + def test_no_process(self) -> None: + """A running job without a live pid cannot be signalled.""" + make_job("scan", pid="5\n") + with patch(_ALIVE, return_value=None), pytest.raises(JobError) as info: + _control.cancel(JOB_ID) + assert "no running process" in info.value.message + + def test_signals_only_the_job_pid(self) -> None: + """SIGTERM goes to the pid, never a process group.""" + make_job("scan", pid="5\n") + with patch(_ALIVE, return_value=5), patch(_KILL) as kill: + _control.cancel(JOB_ID) + kill.assert_called_once_with(5, signal.SIGTERM) diff --git a/steam_backlog_enforcer/tests/test_jobs_daemon_jobs.py b/steam_backlog_enforcer/tests/test_jobs_daemon_jobs.py new file mode 100644 index 0000000..1d57248 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_daemon_jobs.py @@ -0,0 +1,90 @@ +"""Tests for ``jobs._daemon_jobs``: daemon ops recorded as finished jobs.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from steam_backlog_enforcer.jobs import _daemon_jobs, _store +from steam_backlog_enforcer.jobs._events import REQUEST_NAME, RESULT_NAME, read_events +from steam_backlog_enforcer.jobs._view import read_job + +if TYPE_CHECKING: + from pathlib import Path + + +def _record( + *, + ok: bool = True, + exit_code: int = _daemon_jobs.EXIT_OK, + data: dict[str, object] | None = None, +) -> str: + return _daemon_jobs.record_daemon_job( + "unblock", + {"minutes": 5}, + created_at="2026-10-10T12:00:00Z", + outcome=_daemon_jobs.DaemonOutcome( + ok=ok, + summary="Store unblocked." if ok else "Daemon refused.", + exit_code=exit_code, + data=data, + ), + ) + + +def _dir(job_id: str) -> Path: + return _store.JOBS_DIR / job_id + + +class TestRecordDaemonJob: + """The files a daemon job leaves behind.""" + + def test_success_writes_full_sequence(self) -> None: + """queued -> running -> log -> result -> succeeded, plus result.json.""" + path = _dir(_record()) + events = read_events(path) + assert [e["type"] for e in events] == [ + "state", + "state", + "log", + "result", + "state", + ] + assert [e.get("state") for e in events if e["type"] == "state"] == [ + "queued", + "running", + "succeeded", + ] + assert events[2]["level"] == "info" + request = json.loads((path / REQUEST_NAME).read_text(encoding="utf-8")) + assert request["via"] == "daemon" + assert request["params"] == {"minutes": 5} + result = json.loads((path / RESULT_NAME).read_text(encoding="utf-8")) + assert result["state"] == "succeeded" + assert result["exit_code"] == 0 + assert "data" not in result + + def test_failure_is_error_level_and_failed(self) -> None: + """A refused op reads as a failed job with its exit code.""" + job_id = _record(ok=False, exit_code=_daemon_jobs.EXIT_REFUSED) + events = read_events(_dir(job_id)) + assert events[2]["level"] == "error" + assert events[-1]["state"] == "failed" + job = read_job(job_id) + assert job["state"] == "failed" + assert job["exit_code"] == 2 + + def test_data_is_json_safe(self) -> None: + """Non-JSON values in data are stringified, not fatal.""" + job_id = _record(data={"until": object, "n": 1}) + result_event = read_events(_dir(job_id))[3] + assert result_event["data"]["n"] == 1 + assert isinstance(result_event["data"]["until"], str) + + def test_prunes_before_writing(self) -> None: + """History is bounded: the oldest finished job makes room.""" + first = _record() + for _ in range(_store.MAX_JOBS): + _record() + assert not _dir(first).exists() + assert len(list(_store.JOBS_DIR.iterdir())) == _store.MAX_JOBS diff --git a/steam_backlog_enforcer/tests/test_jobs_events.py b/steam_backlog_enforcer/tests/test_jobs_events.py new file mode 100644 index 0000000..e3fc0e8 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_events.py @@ -0,0 +1,108 @@ +"""Tests for ``jobs._events``: the JSONL log and its gap-free writer.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from steam_backlog_enforcer.jobs._events import ( + EVENTS_NAME, + EventWriter, + append_jsonl, + now_iso, + read_events, + read_jsonl, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestNowIso: + """The timestamp format.""" + + def test_second_precision_with_z(self) -> None: + """ISO-8601, UTC, ``Z`` suffix.""" + stamp = now_iso() + assert len(stamp) == len("2026-10-10T12:00:00Z") + assert stamp.endswith("Z") + + +class TestReadJsonl: + """Parsing tolerates torn and damaged lines.""" + + def test_missing_file_is_empty(self, tmp_path: Path) -> None: + """No file, no entries.""" + assert read_jsonl(tmp_path / "nope.jsonl") == [] + + def test_skips_torn_final_line(self, tmp_path: Path) -> None: + """A line without its newline is still being written.""" + path = tmp_path / "x.jsonl" + path.write_text('{"a": 1}\n{"b": 2', encoding="utf-8") + assert read_jsonl(path) == [{"a": 1}] + + def test_skips_bad_and_non_object_lines(self, tmp_path: Path) -> None: + """Garbage and non-dict JSON are dropped.""" + path = tmp_path / "x.jsonl" + path.write_text('{"a": 1}\nnot json\n[1, 2]\n{"c": 3}\n', encoding="utf-8") + assert read_jsonl(path) == [{"a": 1}, {"c": 3}] + + def test_whole_file_without_newline_is_incomplete(self, tmp_path: Path) -> None: + """A single unterminated line yields nothing.""" + path = tmp_path / "x.jsonl" + path.write_text('{"a": 1}', encoding="utf-8") + assert read_jsonl(path) == [] + + +class TestAppendJsonl: + """Appending one object per line.""" + + def test_appends_lines(self, tmp_path: Path) -> None: + """Each call adds one parseable line.""" + path = tmp_path / "x.jsonl" + append_jsonl(path, {"a": 1}) + append_jsonl(path, {"b": 2}) + assert read_jsonl(path) == [{"a": 1}, {"b": 2}] + + +class TestReadEvents: + """Filtering by ``seq``.""" + + def test_after_seq_and_non_int_seq(self, tmp_path: Path) -> None: + """Only int ``seq`` greater than the cursor is returned.""" + lines = [{"seq": 1}, {"seq": 2}, {"seq": "3"}, {"type": "x"}] + (tmp_path / EVENTS_NAME).write_text( + "".join(json.dumps(e) + "\n" for e in lines), encoding="utf-8" + ) + assert read_events(tmp_path) == [{"seq": 1}, {"seq": 2}] + assert read_events(tmp_path, after_seq=1) == [{"seq": 2}] + + +class TestEventWriter: + """Gap-free sequence numbers, even with another writer around.""" + + def test_sequence_and_fields(self, tmp_path: Path) -> None: + """Events number from 1 and carry type, timestamp and fields.""" + writer = EventWriter(tmp_path) + first = writer.emit("state", state="running") + second = writer.emit("log", level="info", message="hi") + assert (first["seq"], second["seq"]) == (1, 2) + assert second["type"] == "log" + assert second["message"] == "hi" + assert "ts" in second + assert read_events(tmp_path) == [first, second] + + def test_continues_after_foreign_append(self, tmp_path: Path) -> None: + """A write by someone else makes the writer recompute ``seq``.""" + writer = EventWriter(tmp_path) + writer.emit("state", state="queued") + other = EventWriter(tmp_path) + other.emit("log", level="info", message="from the server") + third = writer.emit("state", state="running") + assert third["seq"] == 3 + assert [e["seq"] for e in read_events(tmp_path)] == [1, 2, 3] + + def test_new_writer_resumes_existing_file(self, tmp_path: Path) -> None: + """The first emit of a fresh writer reads the last ``seq`` on disk.""" + EventWriter(tmp_path).emit("state", state="queued") + assert EventWriter(tmp_path).emit("state", state="running")["seq"] == 2 diff --git a/steam_backlog_enforcer/tests/test_jobs_job_io.py b/steam_backlog_enforcer/tests/test_jobs_job_io.py new file mode 100644 index 0000000..afdd8aa --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_job_io.py @@ -0,0 +1,160 @@ +"""Tests for ``jobs._job_io``: echo, progress and logging as job events.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer.jobs._events import EventWriter, read_events +from steam_backlog_enforcer.jobs._job_io import ( + EventLogHandler, + JobLogSink, + JobProgress, + _level_for, +) + +if TYPE_CHECKING: + from pathlib import Path + +_CLOCK = "steam_backlog_enforcer.jobs._job_io.time.monotonic" + + +class TestLevelFor: + """Severity guessed from CLI phrasing.""" + + def test_levels(self) -> None: + """Error/failed prefixes, warning markers, else info.""" + assert _level_for("Error: boom") == "error" + assert _level_for("failed to x") == "error" + assert _level_for("Warning: careful") == "warning" + assert _level_for(" ⚠ odd") == "warning" + assert _level_for("x ⚠ y") == "warning" + assert _level_for("all fine") == "info" + + +class TestJobLogSink: + """The terminal-like line buffer.""" + + def test_one_event_per_line(self, tmp_path: Path) -> None: + """Newlines flush; partial text waits for its newline.""" + sink = JobLogSink(EventWriter(tmp_path)) + sink("hel") + sink("lo\nworld") + assert [e["message"] for e in read_events(tmp_path)] == ["hello"] + sink.flush() + assert [e["message"] for e in read_events(tmp_path)] == ["hello", "world"] + assert sink.last_line == "world" + + def test_carriage_return_discards_unfinished_line(self, tmp_path: Path) -> None: + """A progress meter collapses into the final line.""" + sink = JobLogSink(EventWriter(tmp_path)) + sink("10%\r50%\r100%\n") + assert [e["message"] for e in read_events(tmp_path)] == ["100%"] + + def test_blank_line_emits_nothing(self, tmp_path: Path) -> None: + """Whitespace-only lines are not events.""" + sink = JobLogSink(EventWriter(tmp_path)) + sink(" \n") + assert read_events(tmp_path) == [] + assert sink.last_line is None + + def test_rule_line_is_not_the_summary(self, tmp_path: Path) -> None: + """A line of box-drawing has no alphanumerics: logged, not remembered.""" + sink = JobLogSink(EventWriter(tmp_path)) + sink("Done\n────\n") + assert sink.last_line == "Done" + assert len(read_events(tmp_path)) == 2 + + def test_remembers_last_error(self, tmp_path: Path) -> None: + """Error-level lines are kept for the job verdict.""" + sink = JobLogSink(EventWriter(tmp_path)) + sink("Error: bad\nok\n") + assert sink.last_error == "Error: bad" + events = read_events(tmp_path) + assert [e["level"] for e in events] == ["error", "info"] + + +class TestJobProgress: + """Throttled progress events.""" + + def test_phase_announces_at_once(self, tmp_path: Path) -> None: + """A phase start always emits, with its total.""" + progress = JobProgress(EventWriter(tmp_path)) + progress.phase("Scanning", 4) + (event,) = read_events(tmp_path) + assert event["label"] == "Scanning" + assert event["total"] == 4 + assert event["step"] == 0 + assert "item" not in event + assert "eta_seconds" not in event + + def test_advance_is_throttled_but_final_step_forced(self, tmp_path: Path) -> None: + """Steps inside the interval are dropped; reaching the total is not.""" + with patch(_CLOCK, return_value=100.0): + progress = JobProgress(EventWriter(tmp_path)) + progress.phase("Scanning", 3) + progress.advance("a") # Throttled. + progress.advance("b") # Throttled. + progress.advance("c") # Final step: forced. + events = read_events(tmp_path) + assert [e["step"] for e in events] == [0, 3] + assert events[-1]["item"] == "c" + + def test_explicit_step_and_eta(self, tmp_path: Path) -> None: + """An explicit step is used; elapsed time yields an ETA.""" + times = iter([0.0, 0.0, 0.0, 10.0]) # init, phase (x2), advance + with patch(_CLOCK, side_effect=lambda: next(times)): + progress = JobProgress(EventWriter(tmp_path)) + progress.phase("Fetching", 10) + progress.advance(step=5) + last = read_events(tmp_path)[-1] + assert last["step"] == 5 + assert last["eta_seconds"] == 10 + + def test_indeterminate_total_is_not_forced(self, tmp_path: Path) -> None: + """Without a total, ``advance`` never counts as the final step.""" + with patch(_CLOCK, return_value=100.0): + progress = JobProgress(EventWriter(tmp_path)) + progress.phase("Working") + progress.advance("x") + assert len(read_events(tmp_path)) == 1 + + +class TestEventLogHandler: + """WARNING-and-above log records reach the UI.""" + + def _handler(self, tmp_path: Path) -> EventLogHandler: + return EventLogHandler(EventWriter(tmp_path)) + + def test_warning_and_error_levels(self, tmp_path: Path) -> None: + """Warning and error records map to their levels.""" + handler = self._handler(tmp_path) + for level, text in ((logging.WARNING, "w"), (logging.CRITICAL, "e")): + handler.emit(logging.LogRecord("x", level, "f", 1, text, None, None)) + events = read_events(tmp_path) + assert [(e["level"], e["message"]) for e in events] == [ + ("warning", "w"), + ("error", "e"), + ] + + def test_bad_format_args_fall_back_to_raw_message(self, tmp_path: Path) -> None: + """A record whose %-formatting fails still logs its template.""" + handler = self._handler(tmp_path) + record = logging.LogRecord("x", logging.ERROR, "f", 1, "n=%d", ("a",), None) + handler.emit(record) + assert read_events(tmp_path)[0]["message"] == "n=%d" + + def test_ignores_info(self, tmp_path: Path) -> None: + """The handler's own level filters INFO out.""" + logger = logging.getLogger("jobs_io_test") + logger.propagate = False + logger.setLevel(logging.DEBUG) + handler = self._handler(tmp_path) + logger.addHandler(handler) + try: + logger.info("chatter") + logger.warning("careful") + finally: + logger.removeHandler(handler) + assert [e["message"] for e in read_events(tmp_path)] == ["careful"] diff --git a/steam_backlog_enforcer/tests/test_jobs_prompter.py b/steam_backlog_enforcer/tests/test_jobs_prompter.py new file mode 100644 index 0000000..edf4cf3 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_prompter.py @@ -0,0 +1,217 @@ +"""Tests for ``jobs._job_prompter``: prompt events out, answers back.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer._prompter import PromptAbortedError, PromptOption +from steam_backlog_enforcer.jobs import _job_prompter +from steam_backlog_enforcer.jobs._errors import JobCancelledError +from steam_backlog_enforcer.jobs._events import ( + ANSWERS_NAME, + CANCEL_NAME, + EventWriter, + append_jsonl, + read_events, +) +from steam_backlog_enforcer.jobs._job_prompter import JobPrompter + +if TYPE_CHECKING: + from pathlib import Path + +_HEX = "steam_backlog_enforcer.jobs._job_prompter.secrets.token_hex" +_TIME = "steam_backlog_enforcer.jobs._job_prompter.time" + + +def _prompter( + tmp_path: Path, + *answers: str, + preset_phrase: str | None = None, + timeout: float = 3600, +) -> JobPrompter: + """A prompter whose prompts ``p1``, ``p2``... are already answered.""" + for number, value in enumerate(answers, 1): + append_jsonl( + tmp_path / ANSWERS_NAME, {"prompt_id": f"p{number}", "value": value} + ) + return JobPrompter( + EventWriter(tmp_path), tmp_path, preset_phrase=preset_phrase, timeout=timeout + ) + + +def _ids(count: int = 6) -> object: + return patch(_HEX, side_effect=[f"p{i}" for i in range(1, count + 1)]) + + +def _warnings(tmp_path: Path) -> list[str]: + return [e["message"] for e in read_events(tmp_path) if e.get("level") == "warning"] + + +class TestWait: + """Blocking for an answer.""" + + def test_interactive(self, tmp_path: Path) -> None: + """A job can always ask.""" + assert _prompter(tmp_path).interactive is True + + def test_cancel_marker_raises(self, tmp_path: Path) -> None: + """A cancel request while blocked cancels the job.""" + (tmp_path / CANCEL_NAME).touch() + with pytest.raises(JobCancelledError): + _prompter(tmp_path)._wait("p1") + + def test_zero_timeout_aborts(self, tmp_path: Path) -> None: + """No answer in time aborts the prompt.""" + with pytest.raises(PromptAbortedError, match="No answer within"): + _prompter(tmp_path, timeout=0)._wait("p1") + + def test_polls_until_answer_arrives(self, tmp_path: Path) -> None: + """An answer written during the wait is picked up.""" + prompter = _prompter(tmp_path, timeout=10) + clock = MagicMock() + clock.monotonic.return_value = 0.0 + + def answer_arrives(_seconds: float) -> None: + append_jsonl(tmp_path / ANSWERS_NAME, {"prompt_id": "p1", "value": "later"}) + + clock.sleep.side_effect = answer_arrives + with patch(_TIME, clock): + assert prompter._wait("p1") == "later" + + def test_other_prompts_answers_are_ignored(self, tmp_path: Path) -> None: + """Only the line for this prompt id counts.""" + prompter = _prompter(tmp_path, "x", timeout=10) + clock = MagicMock() + clock.monotonic.side_effect = [0.0, 1.0, 100.0] + with patch(_TIME, clock), pytest.raises(PromptAbortedError): + prompter._wait("p2") + + +class TestAsk: + """The state/prompt event pair around a question.""" + + def test_events_around_a_question(self, tmp_path: Path) -> None: + """waiting_input, the prompt, then running again.""" + with _ids(): + assert _prompter(tmp_path, "hello").text(" Name? ") == "hello" + events = read_events(tmp_path) + assert [e["type"] for e in events] == ["state", "prompt", "state"] + assert events[0]["state"] == "waiting_input" + assert events[1]["message"] == "Name?" + assert events[1]["kind"] == "text" + assert events[2]["state"] == "running" + + +class TestChoice: + """Only offered values are accepted.""" + + OPTIONS = (PromptOption("a", "Alpha", "first"), PromptOption("b", "Beta")) + + def test_valid_answer_and_payload(self, tmp_path: Path) -> None: + """The payload carries labels, with detail only when present.""" + with _ids(): + assert _prompter(tmp_path, "b").choice("Pick", self.OPTIONS) == "b" + prompt = next(e for e in read_events(tmp_path) if e["type"] == "prompt") + assert prompt["options"] == [ + {"value": "a", "label": "Alpha", "detail": "first"}, + {"value": "b", "label": "Beta"}, + ] + + def test_invalid_answer_asks_again(self, tmp_path: Path) -> None: + """A value outside the options is warned about and re-asked.""" + with _ids(): + assert _prompter(tmp_path, "zzz", "a").choice("Pick", self.OPTIONS) == "a" + assert _warnings(tmp_path) == ["Not one of the options: 'zzz'"] + + +class TestConfirm: + """Yes/no with a default.""" + + @pytest.mark.parametrize( + ("answer", "default", "expected"), + [ + ("", True, True), + (" ", False, False), + ("YES", False, True), + ("n", True, False), + ], + ) + def test_answers( + self, tmp_path: Path, answer: str, *, default: bool, expected: bool + ) -> None: + """Empty means default; yes/no spellings are case-insensitive.""" + with _ids(): + prompter = _prompter(tmp_path, answer) + assert prompter.confirm("Sure?", default=default) is expected + + def test_garbage_asks_again(self, tmp_path: Path) -> None: + """An unclear answer is warned about and re-asked.""" + with _ids(): + assert _prompter(tmp_path, "maybe", "1").confirm("Sure?", default=False) + assert _warnings(tmp_path) == ["Answer yes or no, not 'maybe'"] + + +class TestPhrase: + """The typed-phrase friction.""" + + def test_matching_preset_is_consumed(self, tmp_path: Path) -> None: + """A phrase sent with the request satisfies the first prompt only.""" + prompter = _prompter(tmp_path, "", preset_phrase="abandon X") + assert prompter.phrase("Sure?", "abandon X") is True + assert read_events(tmp_path) == [] + with _ids(), pytest.raises(PromptAbortedError, match="Declined"): # Asks now. + prompter.phrase("Sure?", "abandon X") + + def test_mismatching_preset_warns_then_asks(self, tmp_path: Path) -> None: + """A wrong preset is not trusted; the user is asked normally.""" + prompter = _prompter(tmp_path, "abandon X", preset_phrase="nope") + with _ids(): + assert prompter.phrase("Sure?", "abandon X") is True + assert _warnings(tmp_path) == [ + "The phrase sent with the request did not match." + ] + + def test_typed_phrase(self, tmp_path: Path) -> None: + """Typing it at the prompt works.""" + with _ids(): + assert _prompter(tmp_path, "abandon X").phrase("Sure?", "abandon X") + prompt = next(e for e in read_events(tmp_path) if e["type"] == "prompt") + assert prompt["phrase"] == "abandon X" + + def test_empty_answer_declines(self, tmp_path: Path) -> None: + """An empty answer aborts rather than saying no.""" + with _ids(), pytest.raises(PromptAbortedError, match="Declined"): + _prompter(tmp_path, " ").phrase("Sure?", "abandon X") + + def test_wrong_three_times_aborts(self, tmp_path: Path) -> None: + """Three misses abort the job, visibly.""" + prompter = _prompter(tmp_path, "a", "b", "c") + with _ids(), pytest.raises(PromptAbortedError, match="Wrong phrase 3 times"): + prompter.phrase("Sure?", "abandon X") + assert len(_warnings(tmp_path)) == _job_prompter.MAX_PHRASE_ATTEMPTS + + +class TestGame: + """Picking from the library browser.""" + + def test_valid_id(self, tmp_path: Path) -> None: + """An offered app id is returned as an int.""" + with _ids(): + assert _prompter(tmp_path, " 620 ").game("Pick", [730, 620]) == 620 + prompt = next(e for e in read_events(tmp_path) if e["type"] == "prompt") + assert prompt["app_ids"] == [620, 730] + + def test_empty_goes_back(self, tmp_path: Path) -> None: + """An empty answer means back to the choice prompt.""" + with _ids(): + assert _prompter(tmp_path, "").game("Pick", [620]) is None + + @pytest.mark.parametrize("bad", ["abc", "999"]) + def test_invalid_answer_asks_again(self, tmp_path: Path, bad: str) -> None: + """Non-digits and ids outside the offer are refused.""" + with _ids(): + assert _prompter(tmp_path, bad, "620").game("Pick", [620]) == 620 + assert _warnings(tmp_path) == [f"Not a game you can pick here: {bad!r}"] diff --git a/steam_backlog_enforcer/tests/test_jobs_registry.py b/steam_backlog_enforcer/tests/test_jobs_registry.py new file mode 100644 index 0000000..5b74af4 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_registry.py @@ -0,0 +1,140 @@ +"""Tests for ``jobs._registry``: which command runs as which job handler.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer._prompter import PromptAbortedError +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.jobs import _registry +from steam_backlog_enforcer.jobs._errors import PrivilegedViaDaemonError +from steam_backlog_enforcer.jobs._specs import JOB_FLAGS + +_PROGRESS = MagicMock() +_PROMPTER = MagicMock() +_PREFIX = "steam_backlog_enforcer.jobs._registry." + + +def test_every_flagged_command_has_a_handler() -> None: + """The two tables describe the same commands.""" + assert set(_registry.HANDLERS) == set(JOB_FLAGS) + + +class TestAssignment: + """The data every backlog job returns.""" + + def test_no_game_assigned(self) -> None: + """An empty name reads as ``None``.""" + data = _registry._assignment(State()) + assert data == {"current_app_id": None, "current_game_name": None} + + def test_game_assigned(self) -> None: + """The assigned game is reported.""" + state = State(current_app_id=620, current_game_name="Portal 2") + assert _registry._assignment(state)["current_game_name"] == "Portal 2" + + +class TestCliWrappers: + """``(config, state)`` and ``(config, state, args)`` commands.""" + + def test_cli_runs_command_and_reports_assignment(self) -> None: + """The command gets the loaded config and state.""" + command = MagicMock() + handler = _registry._cli(command) + data = handler({}, _PROGRESS, _PROMPTER) + config, state = command.call_args.args + assert isinstance(state, State) + assert config is not None + assert data == { + "current_app_id": state.current_app_id, + "current_game_name": None, + } + + def test_with_args_passes_the_param_as_argv(self) -> None: + """One stringified positional arg.""" + command = MagicMock() + handler = _registry._with_args(command, "app_id") + handler({"app_id": 620}, _PROGRESS, _PROMPTER) + assert command.call_args.args[2] == ["620"] + + +class TestAddException: + """``add-exception --reason ``.""" + + def test_builds_the_cli_argv(self) -> None: + """Params become the argument list the CLI parses.""" + with patch(_PREFIX + "cmd_add_exception") as command: + data = _registry._add_exception( + {"app_id": 620, "reason": "one two three four five"}, + _PROGRESS, + _PROMPTER, + ) + command.assert_called_once_with(["620", "--reason", "one two three four five"]) + assert data == {"app_id": 620} + + +class TestEnforceDemo: + """Only the demo runs as a job.""" + + def test_demo_runs_cmd_enforce(self) -> None: + """The demo gets ``--demo`` and reports the exit code.""" + with patch(_PREFIX + "cmd_enforce", return_value=0) as command: + data = _registry._enforce_demo({"demo": 1}, _PROGRESS, _PROMPTER) + assert command.call_args.args[2] == ["--demo"] + assert data == {"exit_code": 0} + + def test_real_enforce_goes_to_daemon(self) -> None: + """Without ``demo`` the job refuses.""" + with pytest.raises(PrivilegedViaDaemonError): + _registry._enforce_demo({"demo": 0}, _PROGRESS, _PROMPTER) + + +class TestRestoreBackup: + """Replacing the state after a typed phrase.""" + + def test_declined_phrase_aborts(self) -> None: + """No phrase, no restore.""" + with ( + patch(_PREFIX + "confirm_phrase", return_value=False), + patch(_PREFIX + "restore_backup") as restore, + pytest.raises(PromptAbortedError), + ): + _registry._restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) + restore.assert_not_called() + + def test_restore_without_previous_state(self) -> None: + """Nothing was replaced, so no second message.""" + with ( + patch(_PREFIX + "confirm_phrase", return_value=True) as confirm, + patch(_PREFIX + "restore_backup", return_value=None), + patch(_PREFIX + "_echo") as echo, + ): + data = _registry._restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) + assert confirm.call_args.kwargs == {"backup_id": "b1"} + echo.assert_called_once_with("Restored backup b1.") + assert data == {"restored": "b1", "previous_backup": None} + + def test_restore_reports_the_saved_previous_state(self) -> None: + """The replaced state's backup id is announced and returned.""" + with ( + patch(_PREFIX + "confirm_phrase", return_value=True), + patch(_PREFIX + "restore_backup", return_value=SimpleNamespace(id="b0")), + patch(_PREFIX + "_echo") as echo, + ): + data = _registry._restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) + assert echo.call_count == 2 + assert data == {"restored": "b1", "previous_backup": "b0"} + + +class TestViaDaemon: + """Privileged commands refuse to run as jobs.""" + + @pytest.mark.parametrize("command", ["gaming-reset", "unblock", "buy-dlc"]) + def test_handler_refuses(self, command: str) -> None: + """The placeholder raises the routing error naming the command.""" + with pytest.raises(PrivilegedViaDaemonError) as info: + _registry.HANDLERS[command]({}, _PROGRESS, _PROMPTER) + assert info.value.command == command diff --git a/steam_backlog_enforcer/tests/test_jobs_runner.py b/steam_backlog_enforcer/tests/test_jobs_runner.py new file mode 100644 index 0000000..02fc88a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_runner.py @@ -0,0 +1,230 @@ +"""Tests for ``jobs._runner``: gating, running and ending one job.""" + +from __future__ import annotations + +import json +import logging +import os +import signal +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._progress import current_progress +from steam_backlog_enforcer._prompter import PromptAbortedError +from steam_backlog_enforcer.jobs import _runner +from steam_backlog_enforcer.jobs._errors import ( + JobCancelledError, + JobError, + PrivilegedViaDaemonError, +) +from steam_backlog_enforcer.jobs._events import ( + PID_NAME, + RESULT_NAME, + EventWriter, + read_events, +) +from steam_backlog_enforcer.jobs._job_io import JobLogSink +from steam_backlog_enforcer.tests._jobs_helpers import make_job + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator, Mapping + from pathlib import Path + + from steam_backlog_enforcer._progress import Progress + from steam_backlog_enforcer._prompter import Prompter + +_GATE = "steam_backlog_enforcer.jobs._runner.enforce_gate" +_REASON = "steam_backlog_enforcer.jobs._runner.lock_reason" +_RUN = "steam_backlog_enforcer.jobs._runner._run_command" + + +@pytest.fixture(autouse=True) +def _no_side_effects() -> Iterator[None]: + """No real signal handlers and no real lock for the fake commands.""" + with ( + patch.object(signal, "signal"), + patch.object(_runner, "_hold_lock", return_value=None), + ): + yield + + +def _run( + handler: Callable[..., Any], + *, + command: str = "scan", + request: dict[str, Any] | None = None, +) -> tuple[_runner._Outcome, Path]: + """Run *handler* as the job for *command* and return the outcome.""" + path = make_job(command) + writer = EventWriter(path) + sink = JobLogSink(writer) + body = request or {"command": command, "params": {"x": 1}} + with patch.dict(_runner.HANDLERS, {command: handler}), patch(_GATE): + return _runner._run_command(path, writer, sink, body), path + + +class TestRunCommand: + """The gate, the handler and the verdict.""" + + def test_unknown_command_is_refused(self) -> None: + """Commands outside the tables never run.""" + path = make_job() + writer = EventWriter(path) + outcome = _runner._run_command( + path, writer, JobLogSink(writer), {"command": "frobnicate"} + ) + assert outcome.exit_code == _runner.EXIT_REFUSED + assert "does not run as a job" in outcome.summary + + def test_success_with_data_and_adapters(self) -> None: + """Echo, progress and params reach the handler; running is announced.""" + seen: dict[str, Any] = {} + + def handler( + params: Mapping[str, object], progress: Progress, prompter: Prompter + ) -> dict[str, int]: + seen["params"] = dict(params) + seen["prompter"] = prompter + progress.phase("Working", 2) + current_progress().advance("a") + _echo("Scanned 2 games") + return {"games": 2} + + outcome, path = _run(handler) + assert (outcome.state, outcome.summary, outcome.data) == ( + "succeeded", + "Scanned 2 games", + {"games": 2}, + ) + assert seen["params"] == {"x": 1} + assert seen["prompter"].interactive is True + types = [e["type"] for e in read_events(path)] + assert "progress" in types + assert {"type": "state", "state": "running"}.items() <= next( + e for e in read_events(path)[2:] if e["type"] == "state" + ).items() + + def test_logging_handler_is_removed_afterwards(self) -> None: + """WARNING records reach the job while it runs, not after.""" + root = logging.getLogger() + before = list(root.handlers) + + def handler(*_args: object) -> None: + logging.getLogger("job.test").warning("careful") + + outcome, path = _run(handler) + assert outcome.state == "succeeded" + assert root.handlers == before + assert any(e.get("message") == "careful" for e in read_events(path)) + + def test_missing_params_default_to_empty(self) -> None: + """A request without params still runs.""" + seen: list[dict[str, Any]] = [] + _run(lambda params, *_: seen.append(dict(params)), request={"command": "scan"}) + assert seen == [{}] + + @pytest.mark.parametrize( + ("reason", "summary"), + [("Total block", "Total block"), (None, "Refused by the lock gate.")], + ) + def test_gate_refusal(self, reason: str | None, summary: str) -> None: + """The gate's banner becomes logs; the summary is the one-line reason.""" + path = make_job() + writer = EventWriter(path) + sink = JobLogSink(writer) + handler = MagicMock() + with ( + patch.dict(_runner.HANDLERS, {"scan": handler}), + patch(_GATE, side_effect=SystemExit(1)), + patch(_REASON, return_value=reason), + ): + outcome = _runner._run_command(path, writer, sink, {"command": "scan"}) + handler.assert_not_called() + assert (outcome.summary, outcome.exit_code) == (summary, _runner.EXIT_REFUSED) + + @pytest.mark.parametrize("code", [0, None]) + def test_clean_system_exit_is_success(self, code: int | None) -> None: + """``sys.exit(0)`` ends the command normally.""" + + def handler(*_args: object) -> None: + _echo("Nothing to do") + raise SystemExit(code) + + outcome, _ = _run(handler) + assert (outcome.state, outcome.summary) == ("succeeded", "Nothing to do") + + def test_failing_system_exit_uses_last_line(self) -> None: + """A non-zero exit fails with the last printed line.""" + + def handler(*_args: object) -> None: + _echo("Could not reach Steam") + raise SystemExit(2) + + outcome, _ = _run(handler) + assert (outcome.state, outcome.summary) == ("failed", "Could not reach Steam") + + def test_failing_system_exit_without_output(self) -> None: + """Silent non-zero exits name the status.""" + + def handler(*_args: object) -> None: + raise SystemExit(3) + + assert _run(handler)[0].summary == "Exited with status 3." + + +class TestRunJob: + """Start to finish, including every way a job can end.""" + + def test_end_to_end_success(self) -> None: + """Pid file, running, result and final state are all written.""" + path = make_job() + (path / "request.json").write_text( + json.dumps({"command": "scan", "params": {}}), encoding="utf-8" + ) + + def handler(*_args: object) -> dict[str, str]: + _echo("Scan complete") + return {"ok": "yes"} + + with patch.dict(_runner.HANDLERS, {"scan": handler}), patch(_GATE): + code = _runner.run_job(path) + assert code == 0 + assert (path / PID_NAME).read_text(encoding="utf-8") == f"{os.getpid()}\n" + result = json.loads((path / RESULT_NAME).read_text(encoding="utf-8")) + assert result["summary"] == "Scan complete" + assert result["data"] == {"ok": "yes"} + assert read_events(path)[-1]["state"] == "succeeded" + + @pytest.mark.parametrize( + ("raised", "state", "code", "summary"), + [ + (JobCancelledError(), "cancelled", 130, "Cancelled."), + (KeyboardInterrupt(), "cancelled", 130, "Cancelled."), + (PromptAbortedError("Declined."), "failed", 1, "Declined."), + (PromptAbortedError(), "failed", 1, "Aborted."), + (JobError("Another job", code="busy"), "failed", 2, "Another job"), + ( + PrivilegedViaDaemonError("unblock"), + "failed", + 2, + "unblock runs in the root daemon, not as a web job.", + ), + (RuntimeError("kaput"), "failed", 1, "RuntimeError: kaput"), + (SystemExit(4), "failed", 1, "SystemExit: 4"), + ], + ) + def test_endings( + self, raised: BaseException, state: str, code: int, summary: str + ) -> None: + """Whatever the command raises, one result and a final state follow.""" + path = make_job() + with patch(_RUN, side_effect=raised): + assert _runner.run_job(path) == code + result = json.loads((path / RESULT_NAME).read_text(encoding="utf-8")) + assert (result["state"], result["summary"]) == (state, summary) + events = read_events(path) + assert [e["type"] for e in events[-2:]] == ["result", "state"] + assert events[-1]["state"] == state diff --git a/steam_backlog_enforcer/tests/test_jobs_runner_parts.py b/steam_backlog_enforcer/tests/test_jobs_runner_parts.py new file mode 100644 index 0000000..551f331 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_runner_parts.py @@ -0,0 +1,183 @@ +"""Tests for the building blocks of ``jobs._runner``: lock, SIGTERM, verdicts.""" + +from __future__ import annotations + +import json +import os +import signal +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer.jobs import _runner, _spawn, _store +from steam_backlog_enforcer.jobs._errors import JobCancelledError, JobError +from steam_backlog_enforcer.jobs._events import ( + RESULT_NAME, + EventWriter, + read_events, +) +from steam_backlog_enforcer.jobs._job_io import JobLogSink +from steam_backlog_enforcer.jobs._specs import JobFlags +from steam_backlog_enforcer.tests._jobs_helpers import make_job + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + +@pytest.fixture(autouse=True) +def sig() -> Iterator[MagicMock]: + """Stand in for ``signal.signal`` so the test process keeps its handlers.""" + with patch.object(signal, "signal") as fake: + yield fake + + +class TestOutcome: + """The verdict record.""" + + def test_ok_only_when_succeeded(self) -> None: + """Only a succeeded state counts as ok.""" + assert _runner._Outcome("succeeded", "x", 0).ok is True + assert _runner._Outcome("failed", "x", 1).ok is False + + def test_failed_defaults_to_exit_one(self) -> None: + """The helper builds a failed outcome.""" + outcome = _runner._failed("boom") + assert (outcome.state, outcome.exit_code) == ("failed", _runner.EXIT_FAILED) + assert _runner._failed("no", _runner.EXIT_REFUSED).exit_code == 2 + + +class TestHoldLock: + """The one-mutating-job lock inside the job process.""" + + def test_read_only_job_holds_nothing(self) -> None: + """Non-mutating jobs skip the lock.""" + assert _runner._hold_lock(JobFlags(mutating=False)) is None + + def test_takes_the_lock_when_run_by_hand( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No inherited descriptor: acquire, and make it non-inheritable.""" + monkeypatch.delenv(_spawn.LOCK_FD_ENV, raising=False) + fd = _runner._hold_lock(JobFlags()) + assert fd is not None + try: + assert os.get_inheritable(fd) is False + with pytest.raises(JobError): + _store.acquire_mutating_lock() + finally: + os.close(fd) + + def test_busy_when_run_by_hand_and_locked( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Another job already holds it.""" + monkeypatch.delenv(_spawn.LOCK_FD_ENV, raising=False) + held = _store.acquire_mutating_lock() + try: + with pytest.raises(JobError) as info: + _runner._hold_lock(JobFlags()) + assert info.value.code == "busy" + finally: + os.close(held) + + def test_accepts_inherited_lock_descriptor( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The server's descriptor is checked against the lock file, then kept.""" + held = _store.acquire_mutating_lock() + monkeypatch.setenv(_spawn.LOCK_FD_ENV, str(held)) + try: + assert _runner._hold_lock(JobFlags()) == held + assert os.get_inheritable(held) is False + assert _spawn.LOCK_FD_ENV not in os.environ + finally: + os.close(held) + + def test_rejects_descriptor_that_is_not_the_lock( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """A reused descriptor number must not pass for the lock.""" + os.close(_store.acquire_mutating_lock()) # Creates the lock file. + other = tmp_path / "other" + other.write_text("", encoding="utf-8") + fd = os.open(other, os.O_RDWR) + monkeypatch.setenv(_spawn.LOCK_FD_ENV, str(fd)) + try: + with pytest.raises(JobError) as info: + _runner._hold_lock(JobFlags()) + assert info.value.code == "busy" + assert "not the lock file" in info.value.message + finally: + os.close(fd) + + +class TestOnSigterm: + """Cancel where that is safe, ignore elsewhere.""" + + def test_cancellable_raises_in_handler(self, sig: MagicMock) -> None: + """The handler raises, and does nothing else.""" + _runner._on_sigterm(cancellable=True) + sigterm, handler = sig.call_args.args + assert sigterm == signal.SIGTERM + with pytest.raises(JobCancelledError): + handler(signal.SIGTERM, None) + + def test_non_cancellable_ignores(self, sig: MagicMock) -> None: + """SIGTERM is ignored for unsafe-to-stop commands.""" + _runner._on_sigterm(cancellable=False) + sig.assert_called_once_with(signal.SIGTERM, signal.SIG_IGN) + + +class TestCompleted: + """A command that returned normally.""" + + def test_printed_error_is_a_failure(self, tmp_path: Path) -> None: + """``Error: ...`` then return is still a failed job.""" + sink = JobLogSink(EventWriter(tmp_path)) + sink("Error: no key\n") + outcome = _runner._completed(sink) + assert (outcome.state, outcome.summary) == ("failed", "Error: no key") + + def test_summary_is_last_line(self, tmp_path: Path) -> None: + """The last text line summarises; unflushed text is flushed.""" + sink = JobLogSink(EventWriter(tmp_path)) + sink("first\nlast") + outcome = _runner._completed(sink, {"a": 1}) + assert (outcome.state, outcome.summary, outcome.data) == ( + "succeeded", + "last", + {"a": 1}, + ) + + def test_silent_command_says_done(self, tmp_path: Path) -> None: + """No output at all reads as ``Done.``.""" + outcome = _runner._completed(JobLogSink(EventWriter(tmp_path))) + assert outcome.summary == "Done." + assert outcome.exit_code == 0 + + +class TestFinish: + """The single result, in the right order.""" + + def test_writes_result_event_file_and_state(self, sig: MagicMock) -> None: + """result event, result.json, then the final state.""" + path = make_job(states=("running",)) + outcome = _runner._Outcome("succeeded", "ok", 0, {"when": object}) + _runner._finish(path, EventWriter(path), outcome) + events = read_events(path) + assert [e["type"] for e in events[-2:]] == ["result", "state"] + assert events[-2]["ok"] is True + assert isinstance(events[-2]["data"]["when"], str) + result = json.loads((path / RESULT_NAME).read_text(encoding="utf-8")) + assert result["state"] == "succeeded" + assert result["exit_code"] == 0 + assert events[-1]["state"] == "succeeded" + sig.assert_called_with(signal.SIGTERM, signal.SIG_IGN) + + def test_no_data_key_without_data(self) -> None: + """A dataless outcome has no ``data`` field.""" + path = make_job(states=("running",)) + _runner._finish(path, EventWriter(path), _runner._failed("bad")) + assert "data" not in read_events(path)[-2] diff --git a/steam_backlog_enforcer/tests/test_jobs_spawn.py b/steam_backlog_enforcer/tests/test_jobs_spawn.py new file mode 100644 index 0000000..287ef66 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_spawn.py @@ -0,0 +1,203 @@ +"""Tests for ``jobs._spawn``: starting the detached job process.""" + +from __future__ import annotations + +import json +import sys +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer.jobs import _spawn +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._events import ( + OUTPUT_NAME, + RESULT_NAME, + EventWriter, + read_events, +) +from steam_backlog_enforcer.tests._jobs_helpers import JOB_ID, make_job + +if TYPE_CHECKING: + from pathlib import Path + +_WHICH = "steam_backlog_enforcer.jobs._spawn.shutil.which" +_PATH = "steam_backlog_enforcer.jobs._spawn.Path" +_POPEN = "steam_backlog_enforcer.jobs._spawn.subprocess.Popen" +_CLOCK = "steam_backlog_enforcer.jobs._spawn.time" + + +class TestSpawnArgv: + """Plain interpreter, or wrapped in a transient systemd scope.""" + + def test_plain_without_systemd_unit(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Outside a unit the job is just the interpreter.""" + monkeypatch.delenv("INVOCATION_ID", raising=False) + argv = _spawn._spawn_argv(make_job()) + assert argv[:3] == [sys.executable, "-m", "steam_backlog_enforcer.jobs"] + assert argv[-2] == "run" + + def test_scope_under_systemd(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Inside the server's unit the job gets its own scope.""" + monkeypatch.setenv("INVOCATION_ID", "abc") + with patch(_WHICH, return_value="/usr/bin/systemd-run"): + argv = _spawn._spawn_argv(make_job()) + assert argv[0] == "/usr/bin/systemd-run" + assert f"--unit=sbe-job-{JOB_ID.lower()}" in argv + assert argv[argv.index("--") + 1] == sys.executable + + def test_plain_when_systemd_run_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No systemd-run on PATH means no wrapping.""" + monkeypatch.setenv("INVOCATION_ID", "abc") + with patch(_WHICH, return_value=None): + assert _spawn._spawn_argv(make_job())[0] == sys.executable + + +class TestIsJobInterpreter: + """Launcher versus the job's own Python.""" + + def test_matches_our_interpreter(self) -> None: + """argv0 equal to sys.executable.""" + fake = MagicMock() + fake.return_value.read_bytes.return_value = f"{sys.executable}\0-m\0x".encode() + with patch(_PATH, fake): + assert _spawn._is_job_interpreter(1) is True + + def test_launcher_is_not(self) -> None: + """systemd-run before it execs.""" + fake = MagicMock() + fake.return_value.read_bytes.return_value = b"/usr/bin/systemd-run\0--user" + with patch(_PATH, fake): + assert _spawn._is_job_interpreter(1) is False + + def test_missing_process(self) -> None: + """A vanished process is not the interpreter.""" + fake = MagicMock() + fake.return_value.read_bytes.side_effect = FileNotFoundError + with patch(_PATH, fake): + assert _spawn._is_job_interpreter(1) is False + + +class TestOutputTail: + """The launcher output shown on failure.""" + + def test_missing_log(self) -> None: + """No output.log, no tail.""" + assert _spawn._output_tail(make_job()) == "" + + def test_tail_is_stripped_and_capped(self) -> None: + """Only the last characters survive.""" + path = make_job() + (path / OUTPUT_NAME).write_text("\n" + "x" * 1000 + "END\n", encoding="utf-8") + tail = _spawn._output_tail(path) + assert len(tail) == 600 + assert tail.endswith("END") + + +class TestFailUnstarted: + """Marking a job that never ran as failed.""" + + def test_writes_log_result_state_and_returns_error(self) -> None: + """The job ends failed, with the launcher output in its log.""" + path = make_job(states=("queued",)) + (path / OUTPUT_NAME).write_text("bus unreachable\n", encoding="utf-8") + err = _spawn._fail_unstarted(path, "Could not start.") + assert err.code == "op_failed" + assert err.message == "Could not start. bus unreachable" + events = read_events(path) + assert events[1]["level"] == "error" + assert "bus unreachable" in events[1]["message"] + assert events[2]["ok"] is False + assert events[-1]["state"] == "failed" + result = json.loads((path / RESULT_NAME).read_text(encoding="utf-8")) + assert result["exit_code"] == 1 + assert result["state"] == "failed" + + +class TestConfirmStarted: + """Waiting for the job to exec, or the launcher to die.""" + + def test_launcher_exit_with_result_is_success(self) -> None: + """A fast job that already finished wrote its own result.""" + path = make_job() + EventWriter(path).emit("result", ok=True, summary="done") + proc = MagicMock(**{"poll.return_value": 0}) + _spawn._confirm_started(proc, path) + + def test_launcher_exit_without_result_fails_job(self) -> None: + """The launcher died before the job ran.""" + path = make_job() + proc = MagicMock(**{"poll.return_value": 3}) + with pytest.raises(JobError) as info: + _spawn._confirm_started(proc, path) + assert info.value.code == "op_failed" + assert "launcher exit 3" in info.value.message + + def test_interpreter_means_started(self) -> None: + """The launcher exec'd into the job's Python.""" + proc = MagicMock(pid=9, **{"poll.return_value": None}) + with patch.object(_spawn, "_is_job_interpreter", return_value=True): + _spawn._confirm_started(proc, make_job()) + + def test_waits_then_gives_up_quietly(self) -> None: + """Past the deadline the reconciler takes over; no error here.""" + proc = MagicMock(pid=9, **{"poll.return_value": None}) + clock = MagicMock() + clock.monotonic.side_effect = [0.0, 1.0, 100.0] + with ( + patch.object(_spawn, "_is_job_interpreter", return_value=False), + patch(_CLOCK, clock), + ): + _spawn._confirm_started(proc, make_job()) + clock.sleep.assert_called_once() + + +class TestSpawnJob: + """Building the environment and starting the process.""" + + def _spawn(self, path: Path, lock_fd: int | None) -> tuple[int, MagicMock]: + popen = MagicMock() + popen.return_value.pid = 321 + with ( + patch(_POPEN, popen), + patch.object(_spawn, "_confirm_started") as confirm, + ): + pid = _spawn.spawn_job(path, lock_fd) + confirm.assert_called_once() + return pid, popen + + def test_with_lock_fd(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The lock descriptor is inherited and announced by env var.""" + monkeypatch.setenv("PYTHONPATH", "/existing") + pid, popen = self._spawn(make_job(), 17) + kwargs = popen.call_args.kwargs + assert pid == 321 + assert kwargs["pass_fds"] == (17,) + assert kwargs["env"][_spawn.LOCK_FD_ENV] == "17" + assert kwargs["env"]["PYTHONPATH"].endswith("/existing") + assert kwargs["start_new_session"] is True + + def test_without_lock_fd(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Read-only jobs inherit nothing and need no PYTHONPATH tail.""" + monkeypatch.delenv("PYTHONPATH", raising=False) + monkeypatch.delenv(_spawn.LOCK_FD_ENV, raising=False) + _, popen = self._spawn(make_job(), None) + kwargs = popen.call_args.kwargs + assert kwargs["pass_fds"] == () + assert _spawn.LOCK_FD_ENV not in kwargs["env"] + assert kwargs["env"]["PYTHONPATH"] == str(_spawn._PACKAGE_ROOT) + + def test_popen_oserror_fails_job(self) -> None: + """An exec failure marks the job failed and raises ``op_failed``.""" + path = make_job() + with ( + patch(_POPEN, side_effect=OSError("no such file")), + pytest.raises(JobError) as info, + ): + _spawn.spawn_job(path, None) + assert info.value.code == "op_failed" + assert "no such file" in info.value.message + assert read_events(path)[-1]["state"] == "failed" diff --git a/steam_backlog_enforcer/tests/test_jobs_specs_errors.py b/steam_backlog_enforcer/tests/test_jobs_specs_errors.py new file mode 100644 index 0000000..892f368 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_specs_errors.py @@ -0,0 +1,101 @@ +"""Tests for ``jobs._specs``, ``jobs._errors`` and ``python -m ...jobs``.""" + +from __future__ import annotations + +from pathlib import Path +import runpy +import sys +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer.jobs import __main__ as jobs_main +from steam_backlog_enforcer.jobs._errors import ( + JobCancelledError, + JobError, + PrivilegedViaDaemonError, +) +from steam_backlog_enforcer.jobs._specs import JOB_FLAGS, JobFlags, is_privileged + + +class TestIsPrivileged: + """Which commands must go to the root daemon.""" + + def test_enforce_demo_runs_as_job(self) -> None: + """Only the demo of ``enforce`` is a job.""" + assert is_privileged("enforce", {"demo": 1}) is False + + def test_real_enforce_is_privileged(self) -> None: + """Anything but the demo is the real enforcer.""" + assert is_privileged("enforce", {}) is True + assert is_privileged("enforce", {"demo": 0}) is True + + def test_privileged_flag_commands(self) -> None: + """Daemon commands carry the privileged flag.""" + assert is_privileged("unblock", {}) is True + assert JOB_FLAGS["unblock"].privileged is True + + def test_plain_command_is_not_privileged(self) -> None: + """Ordinary commands run as jobs.""" + assert is_privileged("scan", {}) is False + + def test_unknown_command_is_not_privileged(self) -> None: + """No flags at all means not privileged (the store rejects it later).""" + assert is_privileged("nope", {}) is False + + def test_flag_defaults(self) -> None: + """A bare JobFlags is mutating, not cancellable, not privileged.""" + assert JobFlags() == JobFlags( + mutating=True, cancellable=False, privileged=False + ) + + +class TestErrors: + """The exception types the job API raises.""" + + def test_job_error_carries_code_and_message(self) -> None: + """The contract code travels with the message.""" + exc = JobError("nope", code="busy") + assert exc.code == "busy" + assert exc.message == "nope" + assert str(exc) == "nope" + + def test_privileged_error_names_command(self) -> None: + """The daemon routing error names the command.""" + exc = PrivilegedViaDaemonError("unblock") + assert exc.command == "unblock" + assert "unblock runs in the root daemon" in str(exc) + + def test_cancelled_is_an_exception(self) -> None: + """JobCancelledError can be raised and caught.""" + with pytest.raises(JobCancelledError): + raise JobCancelledError + + +class TestMain: + """The ``python -m steam_backlog_enforcer.jobs`` entry point.""" + + def test_run_action_calls_run_job(self, tmp_path: Path) -> None: + """``run `` hands the path to the runner and returns its code.""" + with patch.object(jobs_main, "run_job", return_value=2) as run: + assert jobs_main.main(["run", str(tmp_path)]) == 2 + run.assert_called_once_with(Path(tmp_path)) + + def test_action_is_required(self) -> None: + """No action is a usage error.""" + with pytest.raises(SystemExit) as info: + jobs_main.main([]) + assert info.value.code == 2 + + def test_module_execution_exits_with_job_code(self, tmp_path: Path) -> None: + """Running the module as a script exits with the runner's code.""" + sys.modules.pop("steam_backlog_enforcer.jobs.__main__", None) + with ( + patch("steam_backlog_enforcer.jobs._runner.run_job", return_value=7), + patch("sys.argv", ["jobs", "run", str(tmp_path)]), + # runpy warns when the module is already imported; run a fresh copy. + patch.dict(sys.modules), + pytest.raises(SystemExit) as info, + ): + runpy.run_module("steam_backlog_enforcer.jobs", run_name="__main__") + assert info.value.code == 7 diff --git a/steam_backlog_enforcer/tests/test_jobs_store.py b/steam_backlog_enforcer/tests/test_jobs_store.py new file mode 100644 index 0000000..9039f4d --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_store.py @@ -0,0 +1,213 @@ +"""Tests for ``jobs._store``: ids, pruning, the lock and job creation.""" + +from __future__ import annotations + +import json +import os +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer.jobs import _store +from steam_backlog_enforcer.jobs._errors import JobError, PrivilegedViaDaemonError +from steam_backlog_enforcer.jobs._events import PID_NAME, REQUEST_NAME, read_events +from steam_backlog_enforcer.tests._jobs_helpers import JOB_ID, make_job + +_GATE = "steam_backlog_enforcer.jobs._store.lock_reason" +_SPAWN = "steam_backlog_enforcer.jobs._store.spawn_job" + + +class TestJobDir: + """Resolving an id to its directory.""" + + def test_valid_id(self) -> None: + """A well-formed id with a request file resolves.""" + path = make_job() + assert _store.job_dir(JOB_ID) == path + + def test_older_id_without_microseconds(self) -> None: + """History from before microsecond ids stays valid.""" + make_job(job_id="20261010T120000Z-abcdef") + assert _store.job_dir("20261010T120000Z-abcdef").name.endswith("abcdef") + + @pytest.mark.parametrize("bad", ["../etc", "nope", "20261010T120000Z-ZZZZZZ"]) + def test_malformed_id_is_not_found(self, bad: str) -> None: + """Path-like and malformed ids are refused.""" + with pytest.raises(JobError) as info: + _store.job_dir(bad) + assert info.value.code == "not_found" + + def test_unknown_id_is_not_found(self) -> None: + """A valid id with no request file is unknown.""" + with pytest.raises(JobError) as info: + _store.job_dir(JOB_ID) + assert info.value.code == "not_found" + + +def test_new_job_ids_are_valid_and_sortable() -> None: + """Fresh ids match the pattern and are distinct.""" + first, second = _store.new_job_id(), _store.new_job_id() + assert _store._ID_PATTERN.fullmatch(first) + assert first != second + + +class TestIsTerminal: + """Whether a job reached a final state.""" + + def test_states(self) -> None: + """Only the last state event counts.""" + assert _store.is_terminal(make_job(states=())) is False + assert ( + _store.is_terminal(make_job(job_id="20261010T120001000000Z-aaaaaa")) + is False + ) + done = make_job( + job_id="20261010T120002000000Z-bbbbbb", states=("running", "failed") + ) + assert _store.is_terminal(done) is True + + +class TestPrune: + """Keeping the history bounded.""" + + def test_missing_dir_is_a_noop(self) -> None: + """No jobs dir, nothing to prune.""" + _store.prune(1) + assert not _store.JOBS_DIR.exists() + + def test_removes_oldest_finished_only(self) -> None: + """Running jobs and foreign names survive; old finished ones go.""" + old = make_job(job_id="20261010T100000000000Z-000001", states=("succeeded",)) + running = make_job(job_id="20261010T100001000000Z-000002", states=("running",)) + newest = make_job(job_id="20261010T100002000000Z-000003", states=("succeeded",)) + (_store.JOBS_DIR / "notes").mkdir() + _store.prune(1) + assert not old.exists() + assert running.exists() + assert newest.exists() + assert (_store.JOBS_DIR / "notes").exists() + + +class TestMutatingLock: + """The one-mutating-job lock.""" + + def test_second_holder_is_busy(self) -> None: + """A held lock makes the next acquire raise ``busy``.""" + fd = _store.acquire_mutating_lock() + try: + with pytest.raises(JobError) as info: + _store.acquire_mutating_lock() + assert info.value.code == "busy" + finally: + os.close(fd) + + def test_released_lock_can_be_retaken(self) -> None: + """Closing the descriptor frees the lock.""" + os.close(_store.acquire_mutating_lock()) + os.close(_store.acquire_mutating_lock()) + + +class TestPrepareJob: + """Validation and the job dir it writes.""" + + def test_unknown_command(self) -> None: + """Commands outside the table are refused.""" + with pytest.raises(JobError) as info: + _store.prepare_job("frobnicate", {}) + assert info.value.code == "unknown_command" + + def test_invalid_params(self) -> None: + """Bad params become ``invalid_params``.""" + with pytest.raises(JobError) as info: + _store.prepare_job("pick-manual", {}) + assert info.value.code == "invalid_params" + + def test_privileged_goes_to_daemon(self) -> None: + """Daemon commands raise the routing error.""" + with pytest.raises(PrivilegedViaDaemonError): + _store.prepare_job("block-gaming", {"days": 3}) + + def test_locked(self) -> None: + """A gate refusal becomes ``locked`` with its reason.""" + with ( + patch(_GATE, return_value="Total gaming block active"), + pytest.raises(JobError) as info, + ): + _store.prepare_job("scan", {}) + assert info.value.code == "locked" + assert "Total gaming block" in info.value.message + + def test_writes_request_and_queued_event(self) -> None: + """The dir holds the request and a ``queued`` state.""" + with patch(_GATE, return_value=None): + path = _store.prepare_job( + "pick-manual", {"app_id": "620"}, confirm_phrase="x" + ) + request = json.loads((path / REQUEST_NAME).read_text(encoding="utf-8")) + assert request["command"] == "pick-manual" + assert request["params"] == {"app_id": 620} + assert request["confirm_phrase"] == "x" + assert [e["state"] for e in read_events(path)] == ["queued"] + + def test_no_phrase_key_without_phrase(self) -> None: + """An absent phrase is not stored.""" + with patch(_GATE, return_value=None): + path = _store.prepare_job("scan", {}) + request = json.loads((path / REQUEST_NAME).read_text(encoding="utf-8")) + assert "confirm_phrase" not in request + + +class TestCreateJob: + """Creating and starting a job.""" + + def test_mutating_job_holds_lock_during_spawn(self) -> None: + """The child gets the lock fd; the server releases its copy after.""" + seen: dict[str, object] = {} + + def fake_spawn(path: object, lock_fd: int | None) -> int: + seen["fd"] = lock_fd + with pytest.raises(JobError): # Still held while spawning. + _store.acquire_mutating_lock() + return 4242 + + with patch(_GATE, return_value=None), patch(_SPAWN, side_effect=fake_spawn): + job_id = _store.create_job("scan", {}) + assert isinstance(seen["fd"], int) + assert (_store.JOBS_DIR / job_id / PID_NAME).read_text( + encoding="utf-8" + ) == "4242\n" + os.close(_store.acquire_mutating_lock()) # Released again. + + def test_read_only_job_takes_no_lock(self) -> None: + """Non-mutating commands run alongside a held lock.""" + fd = _store.acquire_mutating_lock() + try: + with ( + patch(_GATE, return_value=None), + patch(_SPAWN, return_value=7) as spawn, + ): + _store.create_job("stats", {}) + assert spawn.call_args.args[1] is None + finally: + os.close(fd) + + def test_busy_when_lock_held(self) -> None: + """A second mutating job is refused before anything is written.""" + fd = _store.acquire_mutating_lock() + try: + with patch(_GATE, return_value=None), pytest.raises(JobError) as info: + _store.create_job("scan", {}) + assert info.value.code == "busy" + finally: + os.close(fd) + + def test_spawn_failure_releases_lock(self) -> None: + """If the process cannot start, the lock is not leaked.""" + err = JobError("could not start", code="op_failed") + with ( + patch(_GATE, return_value=None), + patch(_SPAWN, side_effect=err), + pytest.raises(JobError), + ): + _store.create_job("scan", {}) + os.close(_store.acquire_mutating_lock()) diff --git a/steam_backlog_enforcer/tests/test_jobs_view.py b/steam_backlog_enforcer/tests/test_jobs_view.py new file mode 100644 index 0000000..7c65191 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_jobs_view.py @@ -0,0 +1,224 @@ +"""Tests for ``jobs._view``: folding job dirs into ``Job`` objects.""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer.jobs import _store, _view +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.jobs._events import ( + REQUEST_NAME, + EventWriter, + read_events, +) +from steam_backlog_enforcer.tests._jobs_helpers import JOB_ID, make_job, write_result + +if TYPE_CHECKING: + from pathlib import Path + +_PATH = "steam_backlog_enforcer.jobs._view.Path" + + +def _proc(cmdline: bytes | None) -> MagicMock: + """A stand-in for ``Path`` whose ``/proc//cmdline`` reads *cmdline*.""" + fake = MagicMock() + if cmdline is None: + fake.return_value.read_bytes.side_effect = FileNotFoundError + else: + fake.return_value.read_bytes.return_value = cmdline + return fake + + +def _age(path: Path, seconds: float) -> None: + """Pretend the request was written *seconds* ago.""" + stamp = (path / REQUEST_NAME).stat().st_mtime - seconds + os.utime(path / REQUEST_NAME, (stamp, stamp)) + + +class TestPidAlive: + """Telling a live job process from a dead or recycled pid.""" + + def test_no_pid_file_is_unknown(self) -> None: + """Before spawn there is nothing to check.""" + assert _view._pid_alive(make_job()) is None + + def test_garbage_pid_is_unknown(self) -> None: + """An unreadable pid file counts as not written yet.""" + assert _view._pid_alive(make_job(pid="soon")) is None + + def test_missing_process_is_dead(self) -> None: + """No /proc entry means the process is gone.""" + path = make_job(pid="999999\n") + with patch(_PATH, _proc(None)): + assert _view._pid_alive(path) is False + + def test_job_cmdline_is_alive(self) -> None: + """Our module and our job id on the command line.""" + path = make_job(pid="5\n") + cmd = f"python\0-m\0steam_backlog_enforcer.jobs\0run\0/x/{JOB_ID}".encode() + with patch(_PATH, _proc(cmd)): + assert _view._pid_alive(path) is True + + def test_recycled_pid_is_dead(self) -> None: + """Some other program on the same pid is not the job.""" + path = make_job(pid="5\n") + with patch(_PATH, _proc(b"vim\0notes.txt")): + assert _view._pid_alive(path) is False + + def test_zombie_is_dead(self) -> None: + """A zombie has an empty command line.""" + path = make_job(pid="5\n") + with patch(_PATH, _proc(b"")): + assert _view._pid_alive(path) is False + + +class TestPidOf: + """The pid of a live job only.""" + + def test_dead_job_has_no_pid(self) -> None: + """Not alive, no pid.""" + assert _view.pid_of(make_job(pid="5\n")) is None + + def test_live_job_returns_pid(self) -> None: + """Alive: the pid from the file.""" + path = make_job(pid="5\n") + with patch.object(_view, "_pid_alive", return_value=True): + assert _view.pid_of(path) == 5 + + +class TestReconcile: + """A non-terminal job whose process died is failed.""" + + def test_terminal_is_left_alone(self) -> None: + """Finished jobs are never touched.""" + path = make_job(states=("running", "succeeded")) + _view._reconcile(path, "succeeded") + assert len(read_events(path)) == 2 + + def test_fresh_job_without_pid_gets_grace(self) -> None: + """Between mkdir and spawn the job is simply young.""" + path = make_job() + _view._reconcile(path, "running") + assert read_events(path)[-1]["state"] == "running" + + def test_old_job_without_pid_fails(self) -> None: + """Past the grace period a pid-less job is dead.""" + path = make_job() + _age(path, 120) + _view._reconcile(path, "running") + assert read_events(path)[-1]["state"] == "failed" + + def test_live_process_is_left_alone(self) -> None: + """A running process means the job is fine.""" + path = make_job(pid="5\n") + with patch.object(_view, "_pid_alive", return_value=True): + _view._reconcile(path, "running") + assert read_events(path)[-1]["state"] == "running" + + def test_dead_process_fails_job_with_result(self) -> None: + """A dead pid gets a result then a failed state.""" + path = make_job(pid="5\n") + _view._reconcile(path, "running") + last_two = read_events(path)[-2:] + assert last_two[0]["type"] == "result" + assert last_two[0]["ok"] is False + assert last_two[1]["state"] == "failed" + + +class TestReadJob: + """Folding the request, events and result into a ``Job``.""" + + def test_unknown_job(self) -> None: + """Unknown ids are ``not_found``.""" + with pytest.raises(JobError) as info: + _view.read_job(JOB_ID) + assert info.value.code == "not_found" + + def test_folds_everything(self) -> None: + """State, timestamps, progress, summary and exit code.""" + path = make_job(states=("queued", "running"), pid="5\n", params={"app_id": 620}) + writer = EventWriter(path) + writer.emit("progress", step=1, total=2, label="Scan", junk="x") + writer.emit("state", state="running") # started_at stays the first. + writer.emit("log", level="info", message="chatter") + writer.emit("result", ok=True, summary="All done") + writer.emit("state", state="succeeded") + write_result(path, exit_code=0) + job = _view.read_job(JOB_ID) + assert job["state"] == "succeeded" + assert job["command"] == "scan" + assert job["params"] == {"app_id": 620} + assert job["progress"] == {"step": 1, "total": 2, "label": "Scan"} + assert job["summary"] == "All done" + assert job["exit_code"] == 0 + events = read_events(path) + assert job["started_at"] == events[1]["ts"] + assert job["ended_at"] == events[-1]["ts"] + + def test_missing_or_bad_result_file(self) -> None: + """No result.json or an unparsable one leaves exit_code unset.""" + path = make_job(states=("succeeded",)) + assert _view.read_job(JOB_ID)["exit_code"] is None + (path / "result.json").write_text("{oops", encoding="utf-8") + assert _view.read_job(JOB_ID)["exit_code"] is None + + def test_dead_job_is_reconciled(self) -> None: + """Reading a job whose process vanished reports it failed.""" + make_job(pid="5\n") + job = _view.read_job(JOB_ID) + assert job["state"] == "failed" + assert job["summary"] == "The job process exited without reporting a result." + + +class TestListJobs: + """The newest-first job list.""" + + def test_no_dir(self) -> None: + """Nothing yet.""" + assert _view.list_jobs() == [] + + def test_newest_first_and_skips_damaged(self) -> None: + """Damaged dirs are skipped; dirs without a request are ignored.""" + make_job(job_id="20261010T100000000000Z-000001", states=("succeeded",)) + make_job(job_id="20261010T100001000000Z-000002", states=("succeeded",)) + bad_json = make_job(job_id="20261010T100002000000Z-000003") + (bad_json / REQUEST_NAME).write_text("{oops", encoding="utf-8") + no_command = make_job(job_id="20261010T100003000000Z-000004") + (no_command / REQUEST_NAME).write_text("{}", encoding="utf-8") + (_store.JOBS_DIR / ".mutating.lock").write_text("", encoding="utf-8") + ids = [job["id"] for job in _view.list_jobs()] + assert ids == [ + "20261010T100001000000Z-000002", + "20261010T100000000000Z-000001", + ] + + def test_caps_at_max_jobs(self) -> None: + """Only MAX_JOBS entries come back.""" + for i in range(3): + make_job(job_id=f"20261010T10000{i}000000Z-00000{i}", states=("succeeded",)) + with patch.object(_store, "MAX_JOBS", 2): + assert len(_view.list_jobs()) == 2 + + +class TestReadJobEvents: + """Events for the SSE stream.""" + + def test_after_seq(self) -> None: + """Only events past the cursor.""" + make_job(states=("queued", "succeeded")) + assert [e["seq"] for e in _view.read_job_events(JOB_ID)] == [1, 2] + assert [e["seq"] for e in _view.read_job_events(JOB_ID, 1)] == [2] + + def test_unknown_job(self) -> None: + """Unknown ids are ``not_found``.""" + with pytest.raises(JobError): + _view.read_job_events(JOB_ID) + + def test_dead_job_stream_ends(self) -> None: + """Reconciliation appends the failed state before reading.""" + make_job(pid="5\n") + assert _view.read_job_events(JOB_ID)[-1]["state"] == "failed" diff --git a/steam_backlog_enforcer/tests/test_main_abandon.py b/steam_backlog_enforcer/tests/test_main_abandon.py index c5498fb..c9a5061 100644 --- a/steam_backlog_enforcer/tests/test_main_abandon.py +++ b/steam_backlog_enforcer/tests/test_main_abandon.py @@ -12,7 +12,6 @@ from steam_backlog_enforcer.main import ( _enforce_manual_pick_lock, _show_manual_pick_lock_message, cmd_abandon_pick, - cmd_status, main, ) from steam_backlog_enforcer.tests._main_helpers import ( @@ -55,7 +54,7 @@ class TestCmdAbandonPick: state = abandonable_state(started_at=OLD_PICK) with ( patch(f"{PKG}._echo"), - patch(f"{PKG}.input", return_value="YES"), + patch("builtins.input", return_value="abandon TestGame"), patch(f"{PKG}.is_game_installed", return_value=False), patch.object(State, "save"), ): @@ -67,13 +66,25 @@ class TestCmdAbandonPick: state = abandonable_state(started_at="not-a-date") with ( patch(f"{PKG}._echo"), - patch(f"{PKG}.input", return_value="YES"), + patch("builtins.input", return_value="abandon TestGame"), patch(f"{PKG}.is_game_installed", return_value=False), patch.object(State, "save"), ): cmd_abandon_pick(Config(), state, ["100"]) assert state.manual_picks == [] + def test_the_old_yes_answer_no_longer_confirms(self) -> None: + """The typed phrase names the game; a bare YES is a "no".""" + state = abandonable_state() + with ( + patch(f"{PKG}._echo"), + patch("builtins.input", return_value="YES"), + patch.object(State, "save") as mock_save, + ): + cmd_abandon_pick(Config(), state, ["100"]) + mock_save.assert_not_called() + assert [p["app_id"] for p in state.manual_picks] == [100] + def test_aborted_when_not_yes(self) -> None: state = abandonable_state() with ( @@ -91,7 +102,7 @@ class TestCmdAbandonPick: state = abandonable_state() with ( patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="abandon TestGame"), patch.object(State, "save"), patch(f"{PKG}.is_game_installed", return_value=True), patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall, @@ -107,7 +118,7 @@ class TestCmdAbandonPick: state = abandonable_state() with ( patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="abandon TestGame"), patch.object(State, "save"), patch(f"{PKG}.is_game_installed", return_value=False), patch(f"{PKG}.uninstall_game") as mock_uninstall, @@ -119,7 +130,7 @@ class TestCmdAbandonPick: state = abandonable_state() with ( patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="abandon TestGame"), patch.object(State, "save"), patch(f"{PKG}.is_game_installed", return_value=True), patch(f"{PKG}.uninstall_game", return_value=False), @@ -176,7 +187,7 @@ class TestAbandonOneOfTwoPicks: state = two_pick_state() with ( patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="abandon SecondGame"), patch.object(State, "save"), patch(f"{PKG}.is_game_installed", return_value=False), ): @@ -202,49 +213,3 @@ class TestAbandonOneOfTwoPicks: assert "picked 2 game(s)" in output assert "abandon-pick 100" in output assert "abandon-pick 200" in output - - def test_status_lists_both_picks(self) -> None: - with ( - patch( - "steam_backlog_enforcer.main.status.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.main.status.get_installed_games", - return_value=[], - ), - patch( - "steam_backlog_enforcer.main.status.report_completion", - return_value=[], - ), - patch("steam_backlog_enforcer.main.status._echo") as mock_echo, - ): - cmd_status(Config(), two_pick_state()) - output = " ".join(str(c) for c in mock_echo.call_args_list) - assert "Manual picks (2)" in output - assert "picked" in output - assert "day(s) ago" in output - - def test_status_shows_age_for_old_picks(self) -> None: - state = two_pick_state() - state.manual_picks[0]["started_at"] = OLD_PICK - state.manual_picks[1]["started_at"] = OLD_PICK - with ( - patch( - "steam_backlog_enforcer.main.status.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.main.status.get_installed_games", - return_value=[], - ), - patch( - "steam_backlog_enforcer.main.status.report_completion", - return_value=[], - ), - patch("steam_backlog_enforcer.main.status._echo") as mock_echo, - ): - cmd_status(Config(), state) - assert "picked 8.0 day(s) ago" in " ".join( - str(c) for c in mock_echo.call_args_list - ) diff --git a/steam_backlog_enforcer/tests/test_main_gate.py b/steam_backlog_enforcer/tests/test_main_gate.py new file mode 100644 index 0000000..2919b16 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_gate.py @@ -0,0 +1,40 @@ +"""Tests for main._gate: the CLI's printing, exiting half of the lock gate.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.main import _gate + +_PKG = "steam_backlog_enforcer.main._gate" +_CONFIGURED = Config(steam_api_key="k", steam_id="i") + + +class TestEnforceGate: + def test_unconfigured_prints_and_exits(self) -> None: + with patch(f"{_PKG}._echo") as echo, pytest.raises(SystemExit) as exc: + _gate.enforce_gate("status", Config()) + assert exc.value.code == 1 + echo.assert_called_once_with(_gate.NOT_CONFIGURED_MSG) + + def test_passes_through_the_loaded_state(self) -> None: + state = State(current_app_id=5) + with ( + patch.object(State, "load", return_value=state), + patch(f"{_PKG}._enforce_total_block_lock") as total, + patch(f"{_PKG}._enforce_manual_pick_lock") as pick, + ): + assert _gate.enforce_gate("scan", _CONFIGURED) is state + total.assert_called_once_with("scan") + pick.assert_called_once_with("scan", state) + + def test_setup_runs_without_a_key(self) -> None: + with ( + patch.object(State, "load", return_value=State()), + patch(f"{_PKG}._enforce_total_block_lock"), + patch(f"{_PKG}._enforce_manual_pick_lock"), + ): + assert isinstance(_gate.enforce_gate("setup", Config()), State) diff --git a/steam_backlog_enforcer/tests/test_main_install.py b/steam_backlog_enforcer/tests/test_main_install.py index 1f3de80..1f9101d 100644 --- a/steam_backlog_enforcer/tests/test_main_install.py +++ b/steam_backlog_enforcer/tests/test_main_install.py @@ -54,11 +54,12 @@ class TestCmdUninstall: f"{PKG}.get_installed_games", return_value=[(440, "TF2"), (730, "CS")], ), - patch(f"{PKG}.uninstall_other_games", return_value=1), - patch("builtins.input", return_value="YES"), + patch(f"{PKG}.uninstall_other_games", return_value=1) as mock_uninstall, + patch("builtins.input", return_value="uninstall 1 games"), patch(f"{PKG}._echo"), ): cmd_uninstall(Config(), state) + mock_uninstall.assert_called_once() def test_aborts(self) -> None: state = State(current_app_id=440) @@ -67,11 +68,13 @@ class TestCmdUninstall: f"{PKG}.get_installed_games", return_value=[(440, "TF2"), (730, "CS")], ), - patch("builtins.input", return_value="no"), + patch("builtins.input", return_value="YES"), + patch(f"{PKG}.uninstall_other_games") as mock_uninstall, patch(f"{PKG}._echo") as mock_echo, ): cmd_uninstall(Config(), state) assert any("Aborted" in str(c) for c in mock_echo.call_args_list) + mock_uninstall.assert_not_called() class TestCmdInstall: diff --git a/steam_backlog_enforcer/tests/test_main_misc.py b/steam_backlog_enforcer/tests/test_main_misc.py index b47c7ab..8cba360 100644 --- a/steam_backlog_enforcer/tests/test_main_misc.py +++ b/steam_backlog_enforcer/tests/test_main_misc.py @@ -1,7 +1,7 @@ """Tests for the main CLI: store, reset, setup, exception and block-gaming commands.""" from datetime import UTC, datetime -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -70,55 +70,67 @@ class TestCmdBuyDlc: assert mock_open.call_args.args[1] == DEFAULT_WINDOW_MINUTES +_RESET_PHRASE = "wipe all enforcer state" + + +def _reset( + state: State, + owned: object, + *, + typed: str = _RESET_PHRASE, + unhidden: int = 2, +) -> list[str]: + """Run ``cmd_reset`` with *typed* as the answer; return what it printed.""" + with ( + patch("builtins.input", return_value=typed), + patch(f"{PKG}.get_all_owned_app_ids", side_effect=owned) + if isinstance(owned, Exception) + else patch(f"{PKG}.get_all_owned_app_ids", return_value=owned), + patch(f"{PKG}.unhide_all_games", return_value=unhidden), + patch(f"{PKG}._echo") as mock_echo, + patch.object(State, "save"), + ): + cmd_reset(Config(), state) + return [str(c.args[0]) for c in mock_echo.call_args_list if c.args] + + class TestCmdReset: """Tests for cmd_reset.""" def test_normal_reset(self) -> None: state = State(current_app_id=1, current_game_name="G", finished_app_ids=[1]) - with ( - patch(f"{PKG}.unblock_store"), - patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), - patch(f"{PKG}.unhide_all_games", return_value=2), - patch(f"{PKG}._echo"), - patch.object(State, "save"), - ): - cmd_reset(Config(), state) - assert state.current_app_id is None - assert state.finished_app_ids == [] + printed = _reset(state, [1, 2]) + assert state.current_app_id is None + assert state.finished_app_ids == [] + assert "Unhidden 2 games." in printed + + def test_leaves_the_store_blocked(self) -> None: + """Reset no longer lifts the store block: that needs ``unblock``.""" + printed = _reset(State(current_app_id=1), []) + assert any("Store left blocked" in line for line in printed) + + def test_wrong_phrase_aborts_and_keeps_state(self) -> None: + state = State(current_app_id=1) + printed = _reset(state, [1], typed="yes") + assert printed == ["Aborted."] + assert state.current_app_id == 1 + + def test_reports_the_backup_it_took(self) -> None: + backup = MagicMock(id="20261010T000000Z-abcdef") + with patch(f"{PKG}.create_backup", return_value=backup): + printed = _reset(State(), []) + assert "State backed up as 20261010T000000Z-abcdef." in printed def test_unhide_fails(self) -> None: state = State(current_app_id=1) - with ( - patch(f"{PKG}.unblock_store"), - patch( - f"{PKG}.get_all_owned_app_ids", - side_effect=OSError("fail"), - ), - patch(f"{PKG}._echo"), - patch.object(State, "save"), - ): - cmd_reset(Config(), state) + printed = _reset(state, OSError("fail")) + assert any("could not unhide games: fail" in line for line in printed) + assert state.current_app_id is None def test_unhide_returns_zero(self) -> None: state = State(current_app_id=1) - with ( - patch(f"{PKG}.unblock_store"), - patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), - patch(f"{PKG}.unhide_all_games", return_value=0), - patch(f"{PKG}._echo"), - patch.object(State, "save"), - ): - cmd_reset(Config(), state) - - def test_no_owned_ids(self) -> None: - state = State(current_app_id=1) - with ( - patch(f"{PKG}.unblock_store"), - patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), - patch(f"{PKG}._echo"), - patch.object(State, "save"), - ): - cmd_reset(Config(), state) + printed = _reset(state, [1, 2], unhidden=0) + assert not any("Unhidden" in line for line in printed) class TestCmdSetup: diff --git a/steam_backlog_enforcer/tests/test_main_misc_gating.py b/steam_backlog_enforcer/tests/test_main_misc_gating.py index 22edf4c..38ba8cf 100644 --- a/steam_backlog_enforcer/tests/test_main_misc_gating.py +++ b/steam_backlog_enforcer/tests/test_main_misc_gating.py @@ -13,33 +13,36 @@ from steam_backlog_enforcer.tests._main_helpers import ( ) PKG = "steam_backlog_enforcer.main.misc" +EXC_PKG = "steam_backlog_enforcer.main.exception" +_PHRASE = "request exception for AppID=440" +_BLOCK_PHRASE = "block all gaming for 14 days" class TestCmdAddException: def test_no_args_prints_usage_and_exits(self) -> None: with ( - patch(f"{PKG}._echo"), + patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), ): cmd_add_exception([]) def test_missing_reason_flag_exits(self) -> None: with ( - patch(f"{PKG}._echo"), + patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), ): cmd_add_exception(["440", "no", "flag"]) def test_non_numeric_app_id_exits(self) -> None: with ( - patch(f"{PKG}._echo"), + patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), ): cmd_add_exception(["notanumber", "--reason", VALID_REASON]) def test_reason_flag_with_no_value_exits(self) -> None: with ( - patch(f"{PKG}._echo"), + patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), ): cmd_add_exception(["440", "--reason"]) @@ -47,34 +50,59 @@ class TestCmdAddException: def test_reason_flag_last_position_with_no_value_exits(self) -> None: # 3 args passes the len/flag guard but --reason is last so reason_parts=[] with ( - patch(f"{PKG}._echo"), + patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), ): cmd_add_exception(["440", "extra", "--reason"]) def test_invalid_reason_exits(self) -> None: with ( - patch(f"{PKG}._echo"), + patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), ): cmd_add_exception(["440", "--reason", "too short"]) def test_add_pending_exception_raises_value_error(self) -> None: with ( - patch(f"{PKG}._echo"), + patch(f"{EXC_PKG}._echo"), + patch("builtins.input", return_value=_PHRASE), patch( - f"{PKG}.add_pending_exception", + f"{EXC_PKG}.add_pending_exception", side_effect=ValueError("already approved"), ), pytest.raises(SystemExit, match="1"), ): cmd_add_exception(["440", "--reason", VALID_REASON]) + def test_wrong_phrase_aborts_before_adding(self) -> None: + with ( + patch(f"{EXC_PKG}._echo") as mock_echo, + patch("builtins.input", return_value="please"), + patch(f"{EXC_PKG}.add_pending_exception") as mock_add, + pytest.raises(SystemExit, match="1"), + ): + cmd_add_exception(["440", "--reason", VALID_REASON]) + mock_add.assert_not_called() + assert mock_echo.call_args.args == ("Aborted.",) + + def test_phrase_names_the_game_from_the_snapshot(self) -> None: + with ( + patch(f"{EXC_PKG}._echo"), + patch(f"{EXC_PKG}.snapshot_game_name", return_value="Team Fortress 2"), + patch( + "builtins.input", return_value="request exception for Team Fortress 2" + ), + patch(f"{EXC_PKG}.add_pending_exception", return_value="done") as add, + ): + cmd_add_exception(["440", "--reason", VALID_REASON]) + add.assert_called_once_with(440, VALID_REASON) + def test_happy_path(self) -> None: with ( - patch(f"{PKG}._echo") as mock_echo, + patch(f"{EXC_PKG}._echo") as mock_echo, + patch("builtins.input", return_value=_PHRASE), patch( - f"{PKG}.add_pending_exception", + f"{EXC_PKG}.add_pending_exception", return_value="Exception approved for AppID 440. Active immediately.", ), ): @@ -119,7 +147,7 @@ class TestCmdBlockGaming: def test_confirmed_starts_block(self) -> None: with ( patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value=_BLOCK_PHRASE), patch(f"{PKG}.start_total_block", return_value=True) as mock_start, ): cmd_block_gaming(["14"]) @@ -128,7 +156,7 @@ class TestCmdBlockGaming: def test_start_failure_exits_nonzero(self) -> None: with ( patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value=_BLOCK_PHRASE), patch(f"{PKG}.start_total_block", return_value=False), pytest.raises(SystemExit) as exc_info, ): diff --git a/steam_backlog_enforcer/tests/test_main_pick_cap.py b/steam_backlog_enforcer/tests/test_main_pick_cap.py index 3c98076..49dd947 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_cap.py +++ b/steam_backlog_enforcer/tests/test_main_pick_cap.py @@ -25,7 +25,7 @@ class TestPickManualCap: patch(f"{PKG}.report_completion", return_value=[]), patch(f"{PKG}._echo"), patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -59,7 +59,7 @@ class TestPickManualCap: patch(f"{PKG}.report_completion", return_value=[]), patch(f"{PKG}._echo") as mock_echo, patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -82,7 +82,7 @@ class TestPickManualCap: patch(f"{PKG}.report_completion", return_value=[]), patch(f"{PKG}._echo"), patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0) as mock_uninstall, patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -105,7 +105,7 @@ class TestPickManualCap: patch(f"{PKG}.report_completion", return_value=[]), patch(f"{PKG}._echo"), patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=False), @@ -121,7 +121,7 @@ class TestPickManualCap: class TestPickManualRetirementNotices: - """A pick retired by the sweep is announced before the YES prompt.""" + """A pick retired by the sweep is announced before the typed-phrase prompt.""" def test_warns_about_uninstall_and_stale_assignment(self) -> None: state = locked_state(app_id=100) diff --git a/steam_backlog_enforcer/tests/test_main_pick_manual.py b/steam_backlog_enforcer/tests/test_main_pick_manual.py index 5e0cfa8..d4922ed 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_manual.py +++ b/steam_backlog_enforcer/tests/test_main_pick_manual.py @@ -41,7 +41,7 @@ class TestCmdPickManual: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), - patch("builtins.input", side_effect=["489830", "YES"]), + patch("builtins.input", side_effect=["489830", "lock in Skyrim SE"]), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -59,7 +59,7 @@ class TestCmdPickManual: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save") as mock_save, patch(f"{PKG}.uninstall_other_games", return_value=2) as mock_uninstall, patch(f"{PKG}.is_game_fully_installed", return_value=False), @@ -89,7 +89,7 @@ class TestCmdPickManual: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -107,7 +107,7 @@ class TestCmdPickManual: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), diff --git a/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py b/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py index 058827f..13da5aa 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py +++ b/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py @@ -21,7 +21,7 @@ class TestPickManualCascade: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games") as mock_uninstall, patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -38,7 +38,7 @@ class TestPickManualCascade: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -56,7 +56,7 @@ class TestPickManualCascade: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -77,7 +77,7 @@ class TestPickManualCascade: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -95,7 +95,7 @@ class TestPickManualCascade: with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -122,7 +122,7 @@ class TestPickManualCascade: patch(f"{PKG}._echo") as mock_echo, # The skip line is printed by the shared hide helper. patch("steam_backlog_enforcer.library_hider._echo", mock_echo), - patch("builtins.input", return_value="YES"), + patch("builtins.input", return_value="lock in Skyrim SE"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), patch(f"{PKG}.is_game_fully_installed", return_value=True), diff --git a/steam_backlog_enforcer/tests/test_main_status_two_picks.py b/steam_backlog_enforcer/tests/test_main_status_two_picks.py new file mode 100644 index 0000000..596a2fc --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_status_two_picks.py @@ -0,0 +1,60 @@ +"""Tests for how ``status`` shows two concurrent manual picks. + +Split out of test_main_abandon.py to keep every test file under 250 lines. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.main import cmd_status +from steam_backlog_enforcer.tests._main_helpers import OLD_PICK, two_pick_state + + +class TestStatusWithTwoPicks: + def test_status_lists_both_picks(self) -> None: + with ( + patch( + "steam_backlog_enforcer.main.status.is_store_blocked", + return_value=False, + ), + patch( + "steam_backlog_enforcer.main.status.get_installed_games", + return_value=[], + ), + patch( + "steam_backlog_enforcer.main.status.report_completion", + return_value=[], + ), + patch("steam_backlog_enforcer.main.status._echo") as mock_echo, + ): + cmd_status(Config(), two_pick_state()) + output = " ".join(str(c) for c in mock_echo.call_args_list) + assert "Manual picks (2)" in output + assert "picked" in output + assert "day(s) ago" in output + + def test_status_shows_age_for_old_picks(self) -> None: + state = two_pick_state() + state.manual_picks[0]["started_at"] = OLD_PICK + state.manual_picks[1]["started_at"] = OLD_PICK + with ( + patch( + "steam_backlog_enforcer.main.status.is_store_blocked", + return_value=False, + ), + patch( + "steam_backlog_enforcer.main.status.get_installed_games", + return_value=[], + ), + patch( + "steam_backlog_enforcer.main.status.report_completion", + return_value=[], + ), + patch("steam_backlog_enforcer.main.status._echo") as mock_echo, + ): + cmd_status(Config(), state) + assert "picked 8.0 day(s) ago" in " ".join( + str(c) for c in mock_echo.call_args_list + ) diff --git a/steam_backlog_enforcer/tests/test_own_pick.py b/steam_backlog_enforcer/tests/test_own_pick.py new file mode 100644 index 0000000..149b73a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_own_pick.py @@ -0,0 +1,155 @@ +"""Tests for _own_pick: choosing your own next game outside the ranked list.""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _own_pick +from steam_backlog_enforcer._prompter import use_prompter +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._fake_prompter import FakePicker, FakePrompter + +_PKG = "steam_backlog_enforcer._own_pick" +_UNTIL = datetime(2099, 1, 2, 3, 4, tzinfo=UTC).isoformat() + + +def _game( + app_id: int, name: str = "G", *, unlocked: int = 5, hours: float = -1 +) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=10, + unlocked_achievements=unlocked, + playtime_minutes=60, + completionist_hours=hours, + ) + + +def _skipping(app_id: int) -> State: + return State(skipped_until={str(app_id): _UNTIL}) + + +class TestIneligibleReason: + def _reason(self, game: GameInfo | None, state: State | None = None) -> str | None: + state = state or State() + return _own_pick.ineligible_reason(7, game, state, state.active_skipped_ids()) + + def test_no_scan_data(self) -> None: + assert self._reason(None) == "No achievement data" + + def test_complete_game(self) -> None: + assert self._reason(_game(7, unlocked=10)) == "Already 100% complete" + + def test_game_marked_finished_below_100(self) -> None: + state = State(finished_app_ids=[7]) + assert self._reason(_game(7), state) == "Marked finished" + + def test_skipped_game_names_the_date(self) -> None: + assert self._reason(_game(7), _skipping(7)) == "Skipped until 2099-01-02" + + def test_skipped_game_without_a_date(self) -> None: + reason = _own_pick.ineligible_reason(7, _game(7), State(), {7}) + assert reason == "Skipped for now" + + def test_unfinished_game_is_eligible(self) -> None: + assert self._reason(_game(7)) is None + + +class TestMatching: + def test_digits_match_the_exact_app_id(self) -> None: + games = [_game(10, "Ten"), _game(100, "Hundred")] + assert [g.app_id for g in _own_pick._matches("10", games)] == [10] + + def test_names_match_by_substring_prefix_first(self) -> None: + games = [_game(1, "The Doom"), _game(2, "Doom Eternal"), _game(3, "Quake")] + hits = _own_pick._matches("doom", games) + assert [g.name for g in hits] == ["Doom Eternal", "The Doom"] + + def test_eligible_drops_complete_and_cooling_down_games(self) -> None: + games = [_game(1), _game(2, unlocked=10), _game(3)] + assert [g.app_id for g in _own_pick._eligible(games, _skipping(3))] == [1] + + +class TestLabel: + def test_with_hours(self) -> None: + option = _own_pick._label(_game(5, "Five", hours=2.5)) + assert option.value == "5" + assert option.label == "Five (AppID=5)" + assert option.detail == "50% achievements, ~2.5h" + + def test_without_hours(self) -> None: + assert _own_pick._label(_game(5)).detail == "50% achievements" + + +class TestPromptOwnPickWithTextSearch: + """The CLI path: no library browser, a typed search.""" + + def _run(self, prompter: FakePrompter, games: list[GameInfo]) -> GameInfo | None: + with use_prompter(prompter), patch(f"{_PKG}._echo") as echo: + self.said = [c.args[0] for c in echo.call_args_list] + result = _own_pick.prompt_own_pick(games, State()) + self.said = [c.args[0] for c in echo.call_args_list] + return result + + def test_empty_search_goes_back(self) -> None: + assert self._run(FakePrompter(texts=[""]), [_game(1)]) is None + + def test_a_single_hit_is_taken_without_asking(self) -> None: + prompter = FakePrompter(texts=["quake"]) + games = [_game(1, "Quake"), _game(2, "Doom")] + assert self._run(prompter, games) is games[0] + assert len(prompter.asked) == 1 + + def test_no_hits_searches_again(self) -> None: + prompter = FakePrompter(texts=["zzz", ""]) + assert self._run(prompter, [_game(1, "Quake")]) is None + assert self.said == ["No unfinished owned game matches 'zzz'."] + + def test_too_many_hits_asks_to_narrow(self) -> None: + games = [_game(i, f"Game {i}") for i in range(1, 17)] + assert self._run(FakePrompter(texts=["game", ""]), games) is None + assert self.said == ["16 games match 'game'; type more of the name."] + + def test_several_hits_ask_which_one(self) -> None: + games = [_game(1, "Doom"), _game(2, "Doom 2")] + prompter = FakePrompter(texts=["doom"], choices=["2"]) + assert self._run(prompter, games) is games[1] + assert prompter.asked[1][1] == ["1", "2", "search-again"] + + def test_search_again_restarts_the_search(self) -> None: + games = [_game(1, "Doom"), _game(2, "Doom 2")] + prompter = FakePrompter(texts=["doom", ""], choices=["search-again"]) + assert self._run(prompter, games) is None + + def test_finished_games_are_not_offered(self) -> None: + prompter = FakePrompter(texts=["done", ""]) + assert self._run(prompter, [_game(1, "Done", unlocked=10)]) is None + assert self.said + + +class TestPromptOwnPickWithLibraryPicker: + """The web path: the whole eligible library is shown at once.""" + + def test_offers_only_eligible_games_and_returns_the_pick(self) -> None: + games = [_game(1), _game(2, unlocked=10), _game(3)] + picker = FakePicker(picks=[3]) + with use_prompter(picker): + assert _own_pick.prompt_own_pick(games, State()) is games[2] + assert picker.offered == [[1, 3]] + + @pytest.mark.parametrize("pick", [None, 2]) + def test_no_pick_or_an_ineligible_one_goes_back(self, pick: int | None) -> None: + games = [_game(1), _game(2, unlocked=10)] + with use_prompter(FakePicker(picks=[pick])): + assert _own_pick.prompt_own_pick(games, State()) is None + + +def test_skip_expiry_is_judged_now() -> None: + expired = (datetime.now(UTC) - timedelta(days=1)).isoformat() + state = State(skipped_until={"1": expired}) + assert _own_pick._eligible([_game(1)], state)[0].app_id == 1 diff --git a/steam_backlog_enforcer/tests/test_owned_apps_cache.py b/steam_backlog_enforcer/tests/test_owned_apps_cache.py new file mode 100644 index 0000000..b710df0 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_owned_apps_cache.py @@ -0,0 +1,162 @@ +"""Tests for the per-game records kept in the owned-apps cache.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _owned_apps_cache as cache_mod +from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.steam_api import SteamAPIError + +_PKG = "steam_backlog_enforcer._owned_apps_cache" +_FULL = { + "appid": 10, + "name": "Half-Life", + "playtime_forever": 120, + "rtime_last_played": 1_700_000_000, + "has_community_visible_stats": True, +} + + +def _write(payload: object) -> None: + cache_mod._OWNED_IDS_CACHE_FILE.write_text(json.dumps(payload), encoding="utf-8") + + +def _configured() -> Config: + return Config(steam_api_key="k", steam_id="sid") + + +class TestOwnedRecord: + """The slice of a GetOwnedGames entry the library shows.""" + + def test_maps_every_field(self) -> None: + assert cache_mod._owned_record(_FULL) == { + "app_id": 10, + "name": "Half-Life", + "playtime_minutes": 120, + "last_played": 1_700_000_000, + "has_stats": True, + } + + def test_missing_fields_default_to_empty(self) -> None: + assert cache_mod._owned_record({"appid": "7"}) == { + "app_id": 7, + "name": "", + "playtime_minutes": 0, + "last_played": 0, + "has_stats": False, + } + + +class TestSaveRecords: + """_save_owned_app_ids_cache stores records only when given games.""" + + def test_games_become_records(self) -> None: + cache_mod._save_owned_app_ids_cache("sid", [10], [_FULL, {"name": "no id"}]) + data: dict[str, Any] = json.loads( + cache_mod._OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8") + ) + assert data["app_ids"] == [10] + assert [g["app_id"] for g in data["games"]] == [10] + + def test_without_games_there_are_no_records(self) -> None: + cache_mod._save_owned_app_ids_cache("sid", [10]) + data = json.loads(cache_mod._OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8")) + assert "games" not in data + + +class TestOwnedCacheStamp: + """The stamp changes whenever the file is rewritten.""" + + def test_absent_file_is_zero(self) -> None: + assert cache_mod.owned_cache_stamp() == (0, 0) + + def test_present_file_reports_mtime_and_size(self) -> None: + _write({"steam_id": "sid"}) + mtime_ns, size = cache_mod.owned_cache_stamp() + assert mtime_ns > 0 + assert size == len(json.dumps({"steam_id": "sid"})) + + +class TestLoadOwnedRecords: + """load_owned_records ignores the TTL but not the account.""" + + def test_returns_stored_records(self) -> None: + records = [{"app_id": 10}] + _write({"steam_id": "sid", "fetched_at": 0, "games": records}) + assert cache_mod.load_owned_records("sid") == records + + @pytest.mark.parametrize( + "payload", + [ + {"steam_id": "other", "games": []}, + {"steam_id": "sid"}, + {"steam_id": "sid", "games": "nope"}, + ["not", "a", "dict"], + ], + ) + def test_unusable_cache_is_none(self, payload: object) -> None: + _write(payload) + assert cache_mod.load_owned_records("sid") is None + + def test_missing_file_is_none(self) -> None: + assert cache_mod.load_owned_records("sid") is None + + def test_corrupt_file_is_none(self) -> None: + cache_mod._OWNED_IDS_CACHE_FILE.write_text("{nope", encoding="utf-8") + assert cache_mod.load_owned_records("sid") is None + + +class TestRefreshOwnedRecords: + """refresh_owned_records asks Steam (mocked) and rewrites the cache.""" + + @pytest.mark.parametrize( + "config", [Config(steam_id="sid"), Config(steam_api_key="k")] + ) + def test_unconfigured_never_asks_steam(self, config: Config) -> None: + with patch(f"{_PKG}.SteamAPIClient") as client: + assert cache_mod.refresh_owned_records(config) is None + client.assert_not_called() + + def test_fetches_caches_and_returns_records(self) -> None: + client = MagicMock() + client.get_owned_games.return_value = [_FULL, {"name": "no id"}] + with patch(f"{_PKG}.SteamAPIClient", return_value=client) as ctor: + records = cache_mod.refresh_owned_records(_configured()) + ctor.assert_called_once_with("k", "sid") + assert records is not None + assert [r["app_id"] for r in records] == [10] + assert cache_mod.load_owned_records("sid") == records + + @pytest.mark.parametrize("error", [OSError("down"), SteamAPIError("bad key")]) + def test_steam_failure_is_none(self, error: Exception) -> None: + with patch(f"{_PKG}.SteamAPIClient", side_effect=error): + assert cache_mod.refresh_owned_records(_configured()) is None + assert cache_mod.owned_cache_stamp() == (0, 0) + + +class TestGetAllOwnedAppIdsCached: + """The cached branch merges cached ids with the snapshot, de-duplicated.""" + + def test_cache_hit_skips_steam(self) -> None: + with ( + patch(f"{_PKG}._load_owned_app_ids_cache", return_value=[1, 2]), + patch(f"{_PKG}.load_snapshot", return_value=[{"app_id": 2}, {"app_id": 3}]), + patch(f"{_PKG}.SteamAPIClient") as client, + ): + assert cache_mod.get_all_owned_app_ids(_configured()) == [1, 2, 3] + client.assert_not_called() + + def test_api_ids_are_merged_without_duplicates(self) -> None: + client = MagicMock() + client.get_owned_games.return_value = [{"appid": 1}, {"appid": 2}] + with ( + patch(f"{_PKG}._load_owned_app_ids_cache", return_value=None), + patch(f"{_PKG}.load_snapshot", return_value=[{"app_id": 2}, {"app_id": 3}]), + patch(f"{_PKG}.SteamAPIClient", return_value=client), + ): + assert cache_mod.get_all_owned_app_ids(_configured()) == [1, 2, 3] diff --git a/steam_backlog_enforcer/tests/test_prompter.py b/steam_backlog_enforcer/tests/test_prompter.py new file mode 100644 index 0000000..fb0a38a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_prompter.py @@ -0,0 +1,140 @@ +"""Tests for _prompter: the CLI prompter and the active-prompter context.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _prompter +from steam_backlog_enforcer._prompter import ( + CliPrompter, + GamePicker, + PromptOption, + confirm_phrase, + current_prompter, + use_prompter, +) +from steam_backlog_enforcer.tests._fake_prompter import FakePicker, FakePrompter + +if TYPE_CHECKING: + from contextlib import AbstractContextManager + +_OPTIONS = [PromptOption("a", "Alpha"), PromptOption("b", "Beta")] + + +def _typed(*answers: str) -> AbstractContextManager[MagicMock]: + """Patch ``input`` to return *answers* in order.""" + return patch("builtins.input", side_effect=list(answers)) + + +class TestCliPrompterChoice: + def test_lists_options_and_returns_the_chosen_value(self) -> None: + with _typed("2"), patch("steam_backlog_enforcer._prompter._echo") as echo: + assert CliPrompter().choice("Pick", _OPTIONS) == "b" + assert [c.args[0] for c in echo.call_args_list] == [" 1. Alpha", " 2. Beta"] + + def test_retries_on_junk_and_out_of_range(self) -> None: + with ( + _typed("x", "0", "3", "1"), + patch("steam_backlog_enforcer._prompter._echo") as echo, + ): + assert CliPrompter().choice("Pick", _OPTIONS) == "a" + said = [c.args[0] for c in echo.call_args_list] + assert "Invalid input: 'x'" in said + assert "Out of range: 0" in said + assert "Out of range: 3" in said + + +class TestCliPrompterConfirm: + @pytest.mark.parametrize( + ("answer", "default", "expected"), + [ + ("y", False, True), + ("YES", False, True), + ("n", True, False), + ("No", True, False), + ("", True, True), + ("", False, False), + ], + ) + def test_answers(self, answer: str, *, default: bool, expected: bool) -> None: + with _typed(answer): + assert CliPrompter().confirm("Sure?", default=default) is expected + + def test_end_of_input_means_the_default(self) -> None: + with patch("builtins.input", side_effect=EOFError): + assert CliPrompter().confirm("Sure?", default=True) is True + + def test_asks_again_after_an_unclear_answer(self) -> None: + with _typed("maybe", "y"), patch("steam_backlog_enforcer._prompter._echo"): + assert CliPrompter().confirm("Sure?", default=False) is True + + def test_hint_shows_the_default(self) -> None: + with _typed("") as typed: + CliPrompter().confirm("Sure?", default=True) + assert typed.call_args.args[0] == "Sure? [Y/n]: " + + +class TestCliPrompterPhraseAndText: + def test_exact_phrase_matches_ignoring_outer_whitespace(self) -> None: + with _typed(" lock in X ") as typed: + assert CliPrompter().phrase("ignored", "lock in X") is True + assert typed.call_args.args[0] == 'Type "lock in X" to confirm: ' + + def test_wrong_phrase_is_a_no(self) -> None: + with _typed("LOCK IN X"): + assert CliPrompter().phrase("ignored", "lock in X") is False + + def test_text_is_stripped(self) -> None: + with _typed(" hello ") as typed: + assert CliPrompter().text("Name") == "hello" + assert typed.call_args.args[0] == "Name: " + + def test_interactive_follows_stdin(self) -> None: + with patch("steam_backlog_enforcer._prompter.sys.stdin") as stdin: + stdin.isatty.return_value = True + assert CliPrompter().interactive is True + stdin.isatty.return_value = False + assert CliPrompter().interactive is False + + +class TestActivePrompter: + def test_defaults_to_the_cli(self) -> None: + assert current_prompter() is _prompter._CLI + + def test_use_prompter_scopes_the_override(self) -> None: + fake = FakePrompter() + with use_prompter(fake) as active: + assert active is fake + assert current_prompter() is fake + assert current_prompter() is _prompter._CLI + + def test_the_override_is_dropped_when_the_body_raises(self) -> None: + with pytest.raises(RuntimeError), use_prompter(FakePrompter()): + raise RuntimeError + assert current_prompter() is _prompter._CLI + + +class TestGamePickerProtocol: + def test_only_prompters_with_a_game_method_are_pickers(self) -> None: + assert isinstance(FakePicker(), GamePicker) + assert not isinstance(FakePrompter(), GamePicker) + assert not isinstance(CliPrompter(), GamePicker) + + +class TestConfirmPhrase: + def test_asks_for_the_commands_phrase(self) -> None: + fake = FakePrompter(phrases=[True]) + with use_prompter(fake): + assert confirm_phrase("pick-manual", "Lock it?", game_name="Doom") is True + assert fake.asked == [("Lock it?", "lock in Doom")] + + def test_a_declined_phrase_is_false(self) -> None: + with use_prompter(FakePrompter(phrases=[False])): + assert confirm_phrase("reset", "Wipe?") is False + + def test_a_command_without_a_phrase_is_an_error_not_a_yes(self) -> None: + with pytest.raises(KeyError, match="no-such-command"): + confirm_phrase("no-such-command", "?") diff --git a/steam_backlog_enforcer/tests/test_scanning_assign_pick.py b/steam_backlog_enforcer/tests/test_scanning_assign_pick.py new file mode 100644 index 0000000..7c56b9d --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_assign_pick.py @@ -0,0 +1,68 @@ +"""Tests for _prompt_user_pick: the ranked list plus "pick my own".""" + +from __future__ import annotations + +from unittest.mock import patch + +from steam_backlog_enforcer._prompter import use_prompter +from steam_backlog_enforcer._scanning_assign import _prompt_user_pick +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._fake_prompter import FakePrompter + +_PKG = "steam_backlog_enforcer._scanning_assign" + + +def _game(app_id: int, name: str, hours: float = -1) -> GameInfo: + return GameInfo( + app_id=app_id, + name=name, + total_achievements=10, + unlocked_achievements=5, + playtime_minutes=60, + completionist_hours=hours, + ) + + +_RANKED = [_game(1, "Short", hours=2.5), _game(2, "Unknown")] + + +class TestPromptUserPick: + def test_choosing_a_ranked_entry_returns_that_game(self) -> None: + prompter = FakePrompter(choices=["1"]) + with use_prompter(prompter): + assert _prompt_user_pick(_RANKED, _RANKED, State()) is _RANKED[1] + + def test_options_carry_hours_when_known_and_end_with_own_pick(self) -> None: + prompter = FakePrompter(choices=["0"]) + with ( + use_prompter(prompter), + patch.object(prompter, "choice", wraps=prompter.choice) as choice, + ): + _prompt_user_pick(_RANKED, _RANKED, State()) + message, options = choice.call_args.args + assert message == "Select game number" + assert [o.label for o in options[:2]] == [ + "Short (AppID=1) (~2.5h)", + "Unknown (AppID=2)", + ] + assert options[-1].value == "own" + + def test_own_pick_returns_the_game_the_user_searched_for(self) -> None: + mine = _game(9, "Mine") + with ( + use_prompter(FakePrompter(choices=["own"])), + patch(f"{_PKG}.prompt_own_pick", return_value=mine) as own, + ): + state = State() + assert _prompt_user_pick(_RANKED, [*_RANKED, mine], state) is mine + own.assert_called_once_with([*_RANKED, mine], state) + + def test_backing_out_of_own_pick_shows_the_list_again(self) -> None: + prompter = FakePrompter(choices=["own", "0"]) + with ( + use_prompter(prompter), + patch(f"{_PKG}.prompt_own_pick", return_value=None), + ): + assert _prompt_user_pick(_RANKED, _RANKED, State()) is _RANKED[0] + assert len(prompter.asked) == 2 diff --git a/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py index c1daa35..5491be9 100644 --- a/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py +++ b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py @@ -154,6 +154,11 @@ class TestPickNextGameGroup2: "steam_backlog_enforcer._scanning_assign._echo", side_effect=lambda *a, **_: echoed.append(a[0]), ), + # The retry messages come from the CLI prompter now. + patch( + "steam_backlog_enforcer._prompter._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), patch( "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, @@ -183,6 +188,11 @@ class TestPickNextGameGroup2: "steam_backlog_enforcer._scanning_assign._echo", side_effect=lambda *a, **_: echoed.append(a[0]), ), + # The retry messages come from the CLI prompter now. + patch( + "steam_backlog_enforcer._prompter._echo", + side_effect=lambda *a, **_: echoed.append(a[0]), + ), patch( "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, diff --git a/steam_backlog_enforcer/tests/test_scanning_part2.py b/steam_backlog_enforcer/tests/test_scanning_part2.py index d31bb9c..a1c51d7 100644 --- a/steam_backlog_enforcer/tests/test_scanning_part2.py +++ b/steam_backlog_enforcer/tests/test_scanning_part2.py @@ -126,51 +126,53 @@ class TestCheckGameTampering: assert result is None +def _client_showing(extra: dict[int, int]) -> MagicMock: + """A Steam client whose games each show ``extra[app_id]`` new unlocks (5 -> 5+n).""" + client = MagicMock() + + def refresh(app_id: int, _name: str, _playtime: int) -> MagicMock: + game = MagicMock() + game.unlocked_achievements = 5 + extra.get(app_id, 0) + return game + + client.refresh_single_game.side_effect = refresh + return client + + class TestDetectTampering: """Tests for detect_tampering.""" + def _run(self, entries: list[dict[str, Any]], client: MagicMock) -> MagicMock: + with ( + patch(f"{PKG}.load_snapshot", return_value=entries), + patch(f"{PKG}.SteamAPIClient", return_value=client), + patch(f"{PKG}._echo") as mock_echo, + patch(f"{PKG}.send_notification") as notify, + ): + detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + self.notify = notify + return mock_echo + def test_no_snapshot(self) -> None: with patch(f"{PKG}.load_snapshot", return_value=None): detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) def test_no_tampering(self) -> None: - entries = [_entry(app_id=1)] - with ( - patch(f"{PKG}.load_snapshot", return_value=entries), - patch(f"{PKG}.SteamAPIClient"), - patch(f"{PKG}._check_game_tampering", return_value=None), - patch(f"{PKG}._echo"), - ): - detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + mock_echo = self._run([_entry(app_id=1)], _client_showing({})) + mock_echo.assert_not_called() + self.notify.assert_not_called() def test_tampering_found(self) -> None: entries = [_entry(app_id=1, name="BadGame")] - with ( - patch(f"{PKG}.load_snapshot", return_value=entries), - patch(f"{PKG}.SteamAPIClient"), - patch( - f"{PKG}._check_game_tampering", - return_value=("BadGame", 1, 3), - ), - patch(f"{PKG}._echo") as mock_echo, - patch(f"{PKG}.send_notification"), - ): - detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + mock_echo = self._run(entries, _client_showing({1: 3})) assert any("TAMPERING" in str(c) for c in mock_echo.call_args_list) + assert any("BadGame (AppID=1): +3" in str(c) for c in mock_echo.call_args_list) + self.notify.assert_called_once() def test_stops_at_limit(self) -> None: - """Stops after _TAMPER_CHECK_LIMIT suspicious games.""" + """Reports only the first _TAMPER_CHECK_LIMIT suspicious games.""" entries = [_entry(app_id=i, name=f"G{i}") for i in range(10)] - with ( - patch(f"{PKG}.load_snapshot", return_value=entries), - patch(f"{PKG}.SteamAPIClient"), - patch( - f"{PKG}._check_game_tampering", - return_value=("Game", 1, 1), - ) as mock_check, - patch(f"{PKG}._echo"), - patch(f"{PKG}.send_notification"), - ): - detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) - # Should stop after 3 (_TAMPER_CHECK_LIMIT) - assert mock_check.call_count == 3 + mock_echo = self._run(entries, _client_showing(dict.fromkeys(range(10), 1))) + reported = [c for c in mock_echo.call_args_list if "AppID=" in str(c)] + assert len(reported) == 3 + assert "G0" in str(reported[0]) diff --git a/steam_backlog_enforcer/tests/test_scanning_tampering_window.py b/steam_backlog_enforcer/tests/test_scanning_tampering_window.py new file mode 100644 index 0000000..6577657 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_scanning_tampering_window.py @@ -0,0 +1,90 @@ +"""Tests for the windowed re-fetch and progress reporting of detect_tampering.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer import _scanning_tampering as tamper +from steam_backlog_enforcer._progress import use_progress +from steam_backlog_enforcer.config import Config, State + +_PKG = "steam_backlog_enforcer._scanning_tampering" + + +def _entry(app_id: int, **overrides: int) -> dict[str, Any]: + entry: dict[str, Any] = { + "app_id": app_id, + "name": f"G{app_id}", + "total_achievements": 10, + "unlocked_achievements": 5, + "playtime_minutes": 60, + } + return entry | overrides + + +def _client(unlocked_by_id: dict[int, int]) -> MagicMock: + client = MagicMock() + + def refresh(app_id: int, _name: str, _playtime: int) -> MagicMock | None: + if app_id not in unlocked_by_id: + return None + return MagicMock(unlocked_achievements=unlocked_by_id[app_id]) + + client.refresh_single_game.side_effect = refresh + return client + + +class TestNeedsRefetch: + def test_exempt_complete_and_unplayed_games_are_not_worth_a_request(self) -> None: + assert not tamper._needs_refetch(_entry(1), {1}) + assert not tamper._needs_refetch(_entry(1, unlocked_achievements=10), set()) + assert not tamper._needs_refetch(_entry(1, playtime_minutes=0), set()) + + def test_an_unfinished_played_game_is_checked(self) -> None: + assert tamper._needs_refetch(_entry(1), set()) + + +class TestRefetchInOrder: + def test_yields_every_entry_in_order_beyond_the_window(self) -> None: + entries = [_entry(i) for i in range(tamper._TAMPER_WINDOW * 2 + 3)] + client = _client({e["app_id"]: 5 for e in entries}) + seen = [e["app_id"] for e, _ in tamper._refetch_in_order(client, entries)] + assert seen == [e["app_id"] for e in entries] + + def test_a_game_steam_cannot_answer_for_comes_back_as_none(self) -> None: + pairs = list(tamper._refetch_in_order(_client({}), [_entry(1)])) + assert pairs[0][1] is None + + def test_closing_early_stops_asking(self) -> None: + entries = [_entry(i) for i in range(tamper._TAMPER_WINDOW * 4)] + client = _client({e["app_id"]: 5 for e in entries}) + stream = tamper._refetch_in_order(client, entries) + next(stream) + stream.close() + assert client.refresh_single_game.call_count < len(entries) + + +class TestDetectTamperingProgress: + def _run(self, entries: list[dict[str, Any]], client: MagicMock) -> MagicMock: + progress = MagicMock() + with ( + patch(f"{_PKG}.load_snapshot", return_value=entries), + patch(f"{_PKG}.SteamAPIClient", return_value=client), + patch(f"{_PKG}._echo"), + patch(f"{_PKG}.send_notification"), + use_progress(progress), + ): + tamper.detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) + return progress + + def test_one_step_per_game_checked(self) -> None: + entries = [_entry(1), _entry(2), _entry(3, unlocked_achievements=10)] + progress = self._run(entries, _client({1: 5, 2: 5})) + progress.phase.assert_called_once_with("Checking other games for tampering", 2) + assert [c.args[0] for c in progress.advance.call_args_list] == ["G1", "G2"] + + def test_hitting_the_limit_completes_the_bar(self) -> None: + entries = [_entry(i) for i in range(6)] + progress = self._run(entries, _client(dict.fromkeys(range(6), 6))) + assert progress.advance.call_args_list[-1].kwargs == {"step": 6} diff --git a/steam_backlog_enforcer/tests/test_steam_api_scan_extras.py b/steam_backlog_enforcer/tests/test_steam_api_scan_extras.py new file mode 100644 index 0000000..04eee10 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_steam_api_scan_extras.py @@ -0,0 +1,88 @@ +"""Tests for the scan-time behaviours of SteamAPIClient: strictness, skips, cancel.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer.steam_api import ( + AchievementInfo, + SteamAPIClient, + SteamAPIError, +) + +_PKG = "steam_backlog_enforcer._steam_api_client" +_ACH = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) + + +class TestStrictAchievementDetails: + def test_a_real_failure_raises_when_strict(self) -> None: + client = SteamAPIClient("key", "id") + with ( + patch.object(client, "_get", side_effect=SteamAPIError("timeout")), + pytest.raises(SteamAPIError, match="timeout"), + ): + client.get_achievement_details(440, strict=True) + + def test_a_no_stats_answer_is_empty_even_when_strict(self) -> None: + client = SteamAPIClient("key", "id") + with ( + patch.object(client, "_get", side_effect=SteamAPIError("400")), + patch(f"{_PKG}.is_no_stats_answer", return_value=True), + ): + assert client.get_achievement_details(440, strict=True) == [] + + +class TestBuildGameListSkips: + def test_known_no_achievement_games_are_not_asked_about(self) -> None: + client = SteamAPIClient("key", "id") + owned = [{"appid": 1, "name": "Bare"}, {"appid": 2, "name": "Rich"}] + with ( + patch.object(client, "get_owned_games", return_value=owned), + patch(f"{_PKG}.skippable_app_ids", return_value={1}), + patch.object( + client, "get_achievement_details", return_value=[_ACH] + ) as details, + ): + games = client.build_game_list() + assert [g.app_id for g in games] == [2] + assert [c.args[0] for c in details.call_args_list] == [2] + + def test_only_answered_games_are_remembered(self) -> None: + client = SteamAPIClient("key", "id") + owned = [{"appid": 1}, {"appid": 2}, {"appid": 3}] + + def details(app_id: int, *, strict: bool) -> list[AchievementInfo]: + assert strict + if app_id == 1: + return [_ACH] + if app_id == 2: + return [] # Steam said "no stats" + msg = "timeout" + raise SteamAPIError(msg) + + with ( + patch.object(client, "get_owned_games", return_value=owned), + patch.object(client, "get_achievement_details", side_effect=details), + patch(f"{_PKG}.record_scan") as record, + ): + client.build_game_list() + _, answered, found, _ = record.call_args.args + assert [g["appid"] for g in answered] == [1, 2] + assert found == {1} + + +class TestCancelledScan: + def test_an_interrupt_propagates_and_drops_queued_requests(self) -> None: + client = SteamAPIClient("key", "id") + + def cancel(_done: int, _total: int) -> None: + raise KeyboardInterrupt + + with ( + patch.object(client, "get_owned_games", return_value=[{"appid": 1}]), + patch.object(client, "get_achievement_details", return_value=[_ACH]), + pytest.raises(KeyboardInterrupt), + ): + client.build_game_list(progress_callback=cancel) diff --git a/steam_backlog_enforcer/tests/test_web_art.py b/steam_backlog_enforcer/tests/test_web_art.py new file mode 100644 index 0000000..f873ca6 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_art.py @@ -0,0 +1,172 @@ +"""Tests for _web_art — the cover lookup order and the CDN fetch fence.""" + +from __future__ import annotations + +import time +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from steam_backlog_enforcer import _web_art +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer.tests._web_request import make_request + +_PKG = "steam_backlog_enforcer._web_art" + + +def _response( + status: int = 200, ctype: str = "image/jpeg", content: bytes = b"JPEG" +) -> MagicMock: + resp = MagicMock() + resp.status_code = status + resp.headers = {"Content-Type": ctype} + resp.content = content + return resp + + +class TestLocalCover: + def test_none_when_steam_cached_nothing(self) -> None: + assert _web_art._local_cover(10) is None + + def test_top_level_portrait(self) -> None: + base = _web_art.LIBRARYCACHE / "10" + base.mkdir(parents=True) + (base / "library_600x900.jpg").write_bytes(b"A") + assert _web_art._local_cover(10) == base / "library_600x900.jpg" + + def test_hashed_subdirectory_portrait(self) -> None: + sub = _web_art.LIBRARYCACHE / "10" / "abc123" + sub.mkdir(parents=True) + (sub / "library_capsule.jpg").write_bytes(b"B") + assert _web_art._local_cover(10) == sub / "library_capsule.jpg" + + +class TestMissMarker: + def test_absent_marker_is_not_active(self) -> None: + assert _web_art._miss_active(1) is False + + def test_garbage_marker_is_not_active(self) -> None: + _web_art.ART_CACHE_DIR.mkdir(parents=True) + _web_art._miss_file(1).write_text("not a number", encoding="utf-8") + assert _web_art._miss_active(1) is False + + def test_recorded_miss_is_active_until_it_lapses(self) -> None: + _web_art._record_miss(1, 60) + assert _web_art._miss_active(1) is True + with patch(f"{_PKG}.time.time", return_value=time.time() + 120): + assert _web_art._miss_active(1) is False + + +class TestStore: + def test_store_writes_and_clears_the_miss(self) -> None: + _web_art._record_miss(2, 60) + _web_art._store(2, b"IMG") + assert _web_art._cache_file(2).read_bytes() == b"IMG" + assert not _web_art._miss_file(2).exists() + + def test_store_without_a_miss_marker(self) -> None: + _web_art._store(3, b"IMG") + assert _web_art._cache_file(3).read_bytes() == b"IMG" + + +class TestFetch: + def test_returns_a_cover_stored_while_waiting(self) -> None: + _web_art._store(4, b"EARLY") + with patch(f"{_PKG}.requests.get") as get: + assert _web_art._fetch(4) == b"EARLY" + get.assert_not_called() + + def test_active_miss_skips_the_network(self) -> None: + _web_art._record_miss(4, 60) + with patch(f"{_PKG}.requests.get") as get: + assert _web_art._fetch(4) is None + get.assert_not_called() + + def test_network_failure_is_remembered_briefly(self) -> None: + with patch(f"{_PKG}.requests.get", side_effect=requests.ConnectionError): + assert _web_art._fetch(5) is None + retry_at = float(_web_art._miss_file(5).read_text(encoding="utf-8")) + assert retry_at - time.time() <= _web_art._FAILED_RETRY_S + + def test_success_is_cached(self) -> None: + with patch(f"{_PKG}.requests.get", return_value=_response()) as get: + assert _web_art._fetch(6) == b"JPEG" + assert get.call_args.args == (_web_art._CDN.format(app_id=6),) + assert _web_art._cache_file(6).read_bytes() == b"JPEG" + + @pytest.mark.parametrize( + ("status", "ctype", "long_retry"), + [ + (404, "text/html", True), + (403, "text/html", True), + (500, "text/html", False), + (200, "text/html", False), + ], + ) + def test_unusable_answer_is_a_remembered_miss( + self, status: int, ctype: str, *, long_retry: bool + ) -> None: + resp = _response(status, ctype) + with patch(f"{_PKG}.requests.get", return_value=resp): + assert _web_art._fetch(7) is None + wait = float(_web_art._miss_file(7).read_text(encoding="utf-8")) - time.time() + assert (wait > _web_art._FAILED_RETRY_S) is long_retry + + def test_oversized_cover_is_a_long_miss(self) -> None: + big = _response(content=b"x" * (_web_art._MAX_BYTES + 1)) + with patch(f"{_PKG}.requests.get", return_value=big): + assert _web_art._fetch(8) is None + wait = float(_web_art._miss_file(8).read_text(encoding="utf-8")) - time.time() + assert wait > _web_art._FAILED_RETRY_S + + +class TestCoverBytes: + def test_prefers_steams_own_cache(self) -> None: + base = _web_art.LIBRARYCACHE / "9" + base.mkdir(parents=True) + (base / "library_600x900.jpg").write_bytes(b"LOCAL") + assert _web_art.cover_bytes(9) == b"LOCAL" + + def test_then_the_server_cache(self) -> None: + _web_art._store(9, b"CACHED") + assert _web_art.cover_bytes(9) == b"CACHED" + + def test_active_miss_means_no_cover(self) -> None: + _web_art._record_miss(9, 60) + assert _web_art.cover_bytes(9) is None + + def test_falls_through_to_the_cdn(self) -> None: + with patch(f"{_PKG}._fetch", return_value=b"NET") as fetch: + assert _web_art.cover_bytes(9) == b"NET" + fetch.assert_called_once_with(9) + + +class TestArtView: + def test_serves_an_owned_cover(self) -> None: + with ( + patch(f"{_PKG}.owned_app_ids", return_value={42}), + patch(f"{_PKG}.cover_bytes", return_value=b"JPG"), + ): + reply = _web_art.art_view(make_request("42")) + assert reply.raw == b"JPG" + assert reply.ctype == "image/jpeg" + assert "max-age" in reply.headers["Cache-Control"] + + @pytest.mark.parametrize("raw", ["abc", "7"]) + def test_refuses_what_is_not_owned(self, raw: str) -> None: + with ( + patch(f"{_PKG}.owned_app_ids", return_value={42}), + pytest.raises(ApiError) as err, + ): + _web_art.art_view(make_request(raw)) + assert err.value.code == "not_found" + + def test_no_cover_is_not_found(self) -> None: + with ( + patch(f"{_PKG}.owned_app_ids", return_value={42}), + patch(f"{_PKG}.cover_bytes", return_value=None), + pytest.raises(ApiError) as err, + ): + _web_art.art_view(make_request("42")) + assert err.value.code == "not_found" diff --git a/steam_backlog_enforcer/tests/test_web_auth.py b/steam_backlog_enforcer/tests/test_web_auth.py new file mode 100644 index 0000000..4245069 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_auth.py @@ -0,0 +1,94 @@ +"""Tests for _web_auth — Host, Origin, token and the owner-only token file.""" + +from __future__ import annotations + +from email.message import Message +import os +import stat + +import pytest + +from steam_backlog_enforcer import _web_auth +from steam_backlog_enforcer._web_errors import ApiError + + +def _headers(**fields: str) -> Message: + message = Message() + for name, value in fields.items(): + message[name] = value + return message + + +class TestToken: + def test_tokens_are_fresh_hex(self) -> None: + first, second = _web_auth.new_token(), _web_auth.new_token() + assert first != second + assert len(first) == 64 + int(first, 16) + + def test_matching_token_passes(self) -> None: + _web_auth.check_token("abc", "abc") + + @pytest.mark.parametrize("supplied", [None, "", "abd"]) + def test_missing_or_wrong_token_is_refused(self, supplied: str | None) -> None: + with pytest.raises(ApiError) as err: + _web_auth.check_token(supplied, "abc") + assert err.value.code == "bad_token" + + +class TestTokenFile: + def test_path_uses_the_runtime_dir(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/x") + path = _web_auth.token_path(8123) + assert str(path) == "/run/x/steam-backlog-enforcer/web-8123.token" + + def test_path_falls_back_to_the_uid_dir( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("XDG_RUNTIME_DIR") + assert str(_web_auth.token_path(1)).startswith(f"/run/user/{os.getuid()}/") + + def test_written_owner_only_and_replaced(self) -> None: + path = _web_auth.write_token_file("tok1", 9000) + assert path.read_text(encoding="ascii") == "tok1\n" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + _web_auth.write_token_file("tok2", 9000) + assert path.read_text(encoding="ascii") == "tok2\n" + assert not path.with_suffix(".tmp").exists() + + +class TestHostAndOrigin: + def test_allowed_hosts(self) -> None: + assert _web_auth.allowed_hosts(80) == {"127.0.0.1:80", "localhost:80"} + + def test_loopback_host_is_normalised(self) -> None: + got = _web_auth.check_host(_headers(Host=" LocalHost:80 "), 80) + assert got == "localhost:80" + + @pytest.mark.parametrize("fields", [{}, {"Host": "evil.example:80"}]) + def test_foreign_or_missing_host_is_refused(self, fields: dict[str, str]) -> None: + with pytest.raises(ApiError) as err: + _web_auth.check_host(_headers(**fields), 80) + assert err.value.code == "bad_host" + + def test_same_origin_passes(self) -> None: + _web_auth.check_origin(_headers(Origin="http://localhost:80"), "localhost:80") + + @pytest.mark.parametrize("fields", [{}, {"Origin": "http://evil.example"}]) + def test_other_origin_is_refused(self, fields: dict[str, str]) -> None: + with pytest.raises(ApiError) as err: + _web_auth.check_origin(_headers(**fields), "localhost:80") + assert err.value.code == "bad_origin" + + +class TestInjectToken: + def test_goes_right_after_head(self) -> None: + out = _web_auth.inject_token(b"t", "T") + assert out == ( + b't' + ) + + def test_prepended_without_a_head(self) -> None: + out = _web_auth.inject_token(b"

x

", "T") + assert out == b'

x

' diff --git a/steam_backlog_enforcer/tests/test_web_daemon.py b/steam_backlog_enforcer/tests/test_web_daemon.py new file mode 100644 index 0000000..fb02a59 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_daemon.py @@ -0,0 +1,159 @@ +"""Tests for _web_daemon — daemon status and the two-phase gaming reset.""" + +from __future__ import annotations + +from http import HTTPStatus +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _web_daemon +from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError +from steam_backlog_enforcer._ctl_types import DaemonInfo, PendingReset, ResetResult +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer.jobs._view import list_jobs +from steam_backlog_enforcer.tests._web_request import make_request, payload_of + +_CLIENT = "steam_backlog_enforcer._web_daemon._ctl_client" +_PENDING = PendingReset("p1", "type me", "t0", "t1", 5) +_INFO = DaemonInfo("running", "2026-01-01T00:00:00Z", 77, None) + + +class TestDaemonCall: + def test_passes_the_result_through(self) -> None: + assert _web_daemon.daemon_call(max, 1, 3) == 3 + + def test_unreachable_becomes_503(self) -> None: + with ( + patch(f"{_CLIENT}.ping", side_effect=DaemonUnreachableError("no socket")), + pytest.raises(ApiError) as err, + ): + _web_daemon.daemon_call(_web_daemon._ctl_client.ping) + assert err.value.status == HTTPStatus.SERVICE_UNAVAILABLE + + def test_refusal_keeps_its_code(self) -> None: + with ( + patch(f"{_CLIENT}.ping", side_effect=CtlError("locked", "no")), + pytest.raises(ApiError) as err, + ): + _web_daemon.daemon_call(_web_daemon._ctl_client.ping) + assert err.value.code == "locked" + + +class TestDaemonView: + def test_unreachable_is_a_state_not_an_error(self) -> None: + with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError): + reply = _web_daemon.daemon_view(make_request()) + assert payload_of(reply)["state"] == "unreachable" + + def test_status_refusal_is_an_error(self) -> None: + with ( + patch(f"{_CLIENT}.status", side_effect=CtlError("busy", "m")), + pytest.raises(ApiError) as err, + ): + _web_daemon.daemon_view(make_request()) + assert err.value.code == "busy" + + def test_reachable_includes_the_journal(self) -> None: + with ( + patch(f"{_CLIENT}.status", return_value=_INFO), + patch(f"{_CLIENT}.journal_tail", return_value=["a", "b"]) as tail, + ): + reply = _web_daemon.daemon_view(make_request()) + tail.assert_called_once_with(_web_daemon._JOURNAL_LINES) + assert reply.payload == { + "state": "running", + "started_at": "2026-01-01T00:00:00Z", + "pid": 77, + "journal_tail": ["a", "b"], + "restart_available_at": None, + } + + @pytest.mark.parametrize( + "failure", [DaemonUnreachableError(), CtlError("busy", "m")] + ) + def test_status_stands_without_its_journal(self, failure: Exception) -> None: + with ( + patch(f"{_CLIENT}.status", return_value=_INFO), + patch(f"{_CLIENT}.journal_tail", side_effect=failure), + ): + reply = _web_daemon.daemon_view(make_request()) + assert payload_of(reply)["journal_tail"] == [] + + +class TestPendingRoutes: + def test_pending_action_maps_the_contract_fields(self) -> None: + assert _web_daemon.pending_action(_PENDING) == { + "id": "p1", + "command": "gaming-reset", + "phrase": "type me", + "armed_at": "t0", + "ready_at": "t1", + "heartbeat_interval_seconds": 5, + } + + def test_heartbeat(self) -> None: + with patch(f"{_CLIENT}.gaming_reset_heartbeat", return_value=_PENDING) as hb: + reply = _web_daemon.heartbeat_view(make_request("p1")) + hb.assert_called_once_with("p1") + assert payload_of(reply)["id"] == "p1" + + def test_cancel(self) -> None: + with patch(f"{_CLIENT}.gaming_reset_cancel") as cancel: + reply = _web_daemon.cancel_pending_view(make_request("p1")) + cancel.assert_called_once_with("p1") + assert reply.status == HTTPStatus.NO_CONTENT + + +class TestConfirmPhrase: + @pytest.mark.parametrize( + ("body", "expected"), + [ + ({}, ""), + ({"confirm_phrase": "a"}, "a"), + ({"confirmPhrase": "b"}, "b"), + ], + ) + def test_reads_either_spelling(self, body: dict[str, str], expected: str) -> None: + assert _web_daemon.confirm_phrase_of(make_request(body=body)) == expected + + def test_rejects_a_non_string(self) -> None: + with pytest.raises(ApiError) as err: + _web_daemon.confirm_phrase_of(make_request(body={"confirm_phrase": 3})) + assert err.value.code == "invalid_params" + + +class TestCommit: + def test_records_a_finished_job(self) -> None: + result = ResetResult("2026-01-01", 1800.0, was_blocked=True, released=["m"]) + with patch(f"{_CLIENT}.gaming_reset_commit", return_value=result) as commit: + reply = _web_daemon.commit_view( + make_request("p1", body={"confirm_phrase": "go"}) + ) + commit.assert_called_once_with("p1", "go") + assert reply.status == HTTPStatus.CREATED + assert "30 min billed" in payload_of(reply)["summary"] + assert [j["id"] for j in list_jobs()] == [payload_of(reply)["id"]] + + def test_failed_op_is_recorded_and_raised(self) -> None: + failure = CtlError("op_failed", "disk full") + with ( + patch(f"{_CLIENT}.gaming_reset_commit", side_effect=failure), + pytest.raises(ApiError) as err, + ): + _web_daemon.commit_view(make_request("p1")) + assert err.value.code == "op_failed" + (job,) = list_jobs() + assert job["summary"] == "disk full" + assert job["exit_code"] == 1 + + def test_refusal_leaves_no_job(self) -> None: + with ( + patch( + f"{_CLIENT}.gaming_reset_commit", + side_effect=CtlError("wrong_phrase", "no"), + ), + pytest.raises(ApiError), + ): + _web_daemon.commit_view(make_request("p1")) + assert list_jobs() == [] diff --git a/steam_backlog_enforcer/tests/test_web_errors.py b/steam_backlog_enforcer/tests/test_web_errors.py new file mode 100644 index 0000000..98b27b7 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_errors.py @@ -0,0 +1,67 @@ +"""Tests for _web_errors — one code, one HTTP status.""" + +from __future__ import annotations + +from http import HTTPStatus + +import pytest + +from steam_backlog_enforcer import _web_errors +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer.jobs._errors import JobError + + +class TestApiError: + def test_status_comes_from_the_code_table(self) -> None: + err = _web_errors.ApiError("no", code="locked") + assert err.status == HTTPStatus.CONFLICT + assert str(err) == "locked: no" + + def test_unknown_code_is_a_bad_gateway(self) -> None: + err = _web_errors.ApiError("x", code="mystery") + assert err.status == HTTPStatus.BAD_GATEWAY + + def test_explicit_status_wins(self) -> None: + err = _web_errors.ApiError("x", code="locked", status=HTTPStatus.GONE) + assert err.status == HTTPStatus.GONE + + def test_body_and_headers_without_a_delay(self) -> None: + err = _web_errors.ApiError("m", code="busy") + assert err.body() == {"error": "busy", "message": "m"} + assert err.headers() == {} + + def test_body_and_headers_with_a_delay(self) -> None: + err = _web_errors.ApiError("m", code="rate_limited", retry_after=0.2) + assert err.body()["retry_after_seconds"] == 0.2 + assert err.headers() == {"Retry-After": "1"} + later = _web_errors.ApiError("m", code="rate_limited", retry_after=61.5) + assert later.headers() == {"Retry-After": "62"} + + +class TestConverters: + def test_not_found(self) -> None: + err = _web_errors.not_found("gone") + assert (err.code, err.status) == ("not_found", HTTPStatus.NOT_FOUND) + + def test_from_job_error(self) -> None: + err = _web_errors.from_job_error(JobError("busy now", code="busy")) + assert (err.code, err.message) == ("busy", "busy now") + + def test_from_ctl_error_keeps_a_known_code_and_delay(self) -> None: + exc = CtlError("rate_limited", "slow", {"retry_after_seconds": 30}) + err = _web_errors.from_ctl_error(exc) + assert (err.code, err.retry_after) == ("rate_limited", 30.0) + + def test_from_ctl_error_maps_unknown_codes_to_op_failed(self) -> None: + err = _web_errors.from_ctl_error(CtlError("weird", "")) + assert (err.code, err.message, err.retry_after) == ("op_failed", "weird", None) + + @pytest.mark.parametrize("retry", ["soon", None]) + def test_from_ctl_error_ignores_a_non_numeric_delay(self, retry: object) -> None: + exc = CtlError("busy", "m", {"retry_after_seconds": retry}) + assert _web_errors.from_ctl_error(exc).retry_after is None + + def test_daemon_unreachable(self) -> None: + err = _web_errors.daemon_unreachable("no socket") + assert err.code == "daemon_unreachable" + assert "no socket" in err.message diff --git a/steam_backlog_enforcer/tests/test_web_io.py b/steam_backlog_enforcer/tests/test_web_io.py new file mode 100644 index 0000000..1bd4fe8 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_io.py @@ -0,0 +1,140 @@ +"""Tests for _web_io — body parsing and reply writing.""" + +from __future__ import annotations + +from http import HTTPStatus +import io +import json +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from steam_backlog_enforcer import _web_io +from steam_backlog_enforcer._web_errors import ApiError + +if TYPE_CHECKING: + from http.server import BaseHTTPRequestHandler + + +class _Handler: + """Just enough of ``BaseHTTPRequestHandler`` for the plumbing.""" + + def __init__(self, headers: dict[str, str], body: bytes = b"") -> None: + self.headers = headers + self.rfile = io.BytesIO(body) + self.wfile = io.BytesIO() + self.sent: list[tuple[str, str]] = [] + self.status: int | None = None + + def send_response(self, status: int) -> None: + self.status = status + + def send_header(self, name: str, value: str) -> None: + self.sent.append((name, value)) + + def end_headers(self) -> None: + self.sent.append(("--", "end")) + + def as_handler(self) -> BaseHTTPRequestHandler: + return cast("BaseHTTPRequestHandler", self) + + +def _read(headers: dict[str, str], body: bytes = b"") -> dict[str, Any]: + return _web_io.read_json_body(_Handler(headers, body).as_handler()) + + +def _body_of(body: bytes, **headers: str) -> dict[str, Any]: + fields = { + "Content-Length": str(len(body)), + "Content-Type": "application/json", + **headers, + } + return _read(fields, body) + + +class TestReplies: + def test_ok_and_no_content(self) -> None: + assert _web_io.ok({"a": 1}).status == HTTPStatus.OK + assert _web_io.no_content().status == HTTPStatus.NO_CONTENT + + +class TestReadJsonBody: + def test_absent_body_is_empty(self) -> None: + assert _read({}) == {} + + def test_parses_an_object(self) -> None: + got = _body_of(b'{"a": 1}', **{"Content-Type": "Application/JSON; x=y"}) + assert got == {"a": 1} + + def test_chunked_needs_a_length(self) -> None: + with pytest.raises(ApiError) as err: + _body_of(b"", **{"Transfer-Encoding": "chunked"}) + assert err.value.status == HTTPStatus.LENGTH_REQUIRED + + def test_non_numeric_length(self) -> None: + with pytest.raises(ApiError) as err: + _read({"Content-Length": "x"}) + assert err.value.status == HTTPStatus.BAD_REQUEST + + def test_oversized_body(self) -> None: + with pytest.raises(ApiError) as err: + _read({"Content-Length": str(_web_io.MAX_BODY_BYTES + 1)}) + assert err.value.status == HTTPStatus.REQUEST_ENTITY_TOO_LARGE + + def test_wrong_content_type(self) -> None: + with pytest.raises(ApiError) as err: + _body_of(b"{}", **{"Content-Type": "text/plain"}) + assert err.value.status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE + + def test_missing_content_type(self) -> None: + with pytest.raises(ApiError) as err: + _read({"Content-Length": "2"}, b"{}") + assert err.value.status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE + + @pytest.mark.parametrize("raw", [b"{nope", b"[1]"]) + def test_malformed_or_non_object(self, raw: bytes) -> None: + with pytest.raises(ApiError) as err: + _body_of(raw) + assert err.value.code == "invalid_params" + + +class TestSending: + def test_send_bytes_with_and_without_a_body(self) -> None: + handler = _Handler({}) + _web_io.send_bytes( + handler.as_handler(), HTTPStatus.OK, b"hi", "text/plain", {"X-A": "1"} + ) + assert handler.wfile.getvalue() == b"hi" + assert ("Content-Length", "2") in handler.sent + assert ("X-A", "1") in handler.sent + empty = _Handler({}) + _web_io.send_bytes(empty.as_handler(), HTTPStatus.OK, b"", "text/plain") + assert empty.wfile.getvalue() == b"" + + def test_raw_reply(self) -> None: + handler = _Handler({}) + reply = _web_io.Reply(HTTPStatus.OK, raw=b"\xff", ctype="image/jpeg") + _web_io.send_reply(handler.as_handler(), reply) + assert handler.wfile.getvalue() == b"\xff" + assert ("Content-Type", "image/jpeg") in handler.sent + + def test_payloadless_reply_has_no_content_type(self) -> None: + handler = _Handler({}) + _web_io.send_reply(handler.as_handler(), _web_io.no_content()) + assert handler.status == HTTPStatus.NO_CONTENT + assert ("Content-Length", "0") in handler.sent + assert "Content-Type" not in [name for name, _ in handler.sent] + + def test_json_reply(self) -> None: + handler = _Handler({}) + _web_io.send_reply(handler.as_handler(), _web_io.ok({"a": 1})) + assert json.loads(handler.wfile.getvalue()) == {"a": 1} + assert ("Cache-Control", "no-store") in handler.sent + + def test_error_reply(self) -> None: + handler = _Handler({}) + error = ApiError("m", code="rate_limited", retry_after=3) + _web_io.send_error(handler.as_handler(), error) + assert handler.status == HTTPStatus.TOO_MANY_REQUESTS + assert ("Retry-After", "3") in handler.sent + assert json.loads(handler.wfile.getvalue())["error"] == "rate_limited" diff --git a/steam_backlog_enforcer/tests/test_web_jobs.py b/steam_backlog_enforcer/tests/test_web_jobs.py new file mode 100644 index 0000000..191fe4f --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_jobs.py @@ -0,0 +1,198 @@ +"""Tests for _web_jobs — job routes and backup restore.""" + +from __future__ import annotations + +from http import HTTPStatus +from typing import Any +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _web_jobs +from steam_backlog_enforcer._backups import StateBackup +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_io import Reply +from steam_backlog_enforcer.jobs._errors import JobError +from steam_backlog_enforcer.tests._web_request import make_request, payload_of + +_PKG = "steam_backlog_enforcer._web_jobs" +_BACKUP = StateBackup("20260101T000000Z-abcdef", "t", "r", 1) + + +def _create(body: dict[str, Any]) -> Reply: + return _web_jobs.create_job_view(make_request(body=body)) + + +class TestEnforceParams: + @pytest.mark.parametrize( + ("params", "expected"), + [ + ({"demo": 1}, {"demo": 1}), + ({"mode": "demo"}, {"demo": 1}), + ({"mode": "restart"}, {"demo": 0}), + ], + ) + def test_translation(self, params: dict[str, Any], expected: object) -> None: + assert _web_jobs._enforce_params(params) == expected + + @pytest.mark.parametrize("params", [{}, {"mode": "x"}, {"mode": "demo", "z": 1}]) + def test_bad_mode(self, params: dict[str, Any]) -> None: + with pytest.raises(ApiError) as err: + _web_jobs._enforce_params(params) + assert err.value.code == "invalid_params" + + +class TestJobRequest: + def test_defaults_to_empty_params(self) -> None: + got = _web_jobs._job_request(make_request(body={"command": "scan"})) + assert got == ("scan", {}, None) + + def test_enforce_params_are_translated(self) -> None: + body = {"command": "enforce", "params": {"mode": "demo"}} + assert _web_jobs._job_request(make_request(body=body))[1] == {"demo": 1} + + @pytest.mark.parametrize( + "body", + [ + {}, + {"command": 3}, + {"command": "scan", "params": []}, + {"command": "scan", "confirm_phrase": 3}, + ], + ) + def test_malformed(self, body: dict[str, Any]) -> None: + with pytest.raises(ApiError) as err: + _web_jobs._job_request(make_request(body=body)) + assert err.value.code == "invalid_params" + + +class TestReadRoutes: + def test_jobs_view(self) -> None: + with patch(f"{_PKG}.list_jobs", return_value=[{"id": "a"}]): + assert _web_jobs.jobs_view(make_request()).payload == [{"id": "a"}] + + def test_job_view(self) -> None: + with patch(f"{_PKG}.read_job", return_value={"id": "a"}) as read: + assert _web_jobs.job_view(make_request("a")).payload == {"id": "a"} + read.assert_called_once_with("a") + + def test_unknown_job(self) -> None: + with pytest.raises(ApiError) as err: + _web_jobs.job_view(make_request("nope")) + assert err.value.code == "not_found" + + +class TestCreateJob: + def test_unknown_command(self) -> None: + with pytest.raises(ApiError) as err: + _create({"command": "rm-rf"}) + assert err.value.code == "unknown_command" + + def test_invalid_params(self) -> None: + with pytest.raises(ApiError) as err: + _create({"command": "scan", "params": {"bogus": 1}}) + assert err.value.code == "invalid_params" + + def test_privileged_goes_to_the_daemon(self) -> None: + sentinel = Reply(HTTPStatus.ACCEPTED) + with patch(f"{_PKG}.run_privileged", return_value=sentinel) as run: + got = _create({"command": "gaming-reset", "confirm_phrase": "p"}) + assert got is sentinel + run.assert_called_once_with("gaming-reset", {}, "p") + + def test_plain_job_is_created(self) -> None: + with ( + patch(f"{_PKG}.create_job", return_value="j1") as create, + patch(f"{_PKG}.read_job", return_value={"id": "j1"}), + ): + got = _create({"command": "scan", "confirm_phrase": "p"}) + create.assert_called_once_with("scan", {}, confirm_phrase="p") + assert (got.status, got.payload) == (HTTPStatus.CREATED, {"id": "j1"}) + + def test_store_refusal(self) -> None: + with ( + patch(f"{_PKG}.create_job", side_effect=JobError("m", code="busy")), + pytest.raises(ApiError) as err, + ): + _create({"command": "scan"}) + assert err.value.code == "busy" + + +class TestAnswerAndCancel: + def test_answer(self) -> None: + body = {"prompt_id": "q", "value": "v"} + with patch(f"{_PKG}.write_answer") as write: + reply = _web_jobs.answer_view(make_request("j", body=body)) + write.assert_called_once_with("j", "q", "v") + assert reply.status == HTTPStatus.NO_CONTENT + + @pytest.mark.parametrize("body", [{}, {"prompt_id": "q", "value": 1}]) + def test_answer_body_is_validated(self, body: dict[str, Any]) -> None: + with pytest.raises(ApiError) as err: + _web_jobs.answer_view(make_request("j", body=body)) + assert err.value.code == "invalid_params" + + def test_answer_refusal(self) -> None: + body = {"prompt_id": "q", "value": "v"} + with pytest.raises(ApiError) as err: + _web_jobs.answer_view(make_request("missing", body=body)) + assert err.value.code == "not_found" + + def test_cancel(self) -> None: + with patch(f"{_PKG}.cancel") as cancel: + reply = _web_jobs.cancel_view(make_request("j")) + cancel.assert_called_once_with("j") + assert reply.status == HTTPStatus.NO_CONTENT + + def test_cancel_refusal(self) -> None: + with pytest.raises(ApiError) as err: + _web_jobs.cancel_view(make_request("missing")) + assert err.value.code == "not_found" + + +class TestRestoreBackup: + def _restore(self, phrase: str, backup_id: str = _BACKUP.id) -> Reply: + with ( + patch(f"{_PKG}.list_backups", return_value=[_BACKUP]), + patch(f"{_PKG}.create_job", return_value="j1") as create, + patch(f"{_PKG}.read_job", return_value={"id": "j1"}), + ): + reply = _web_jobs.restore_backup_view( + make_request(backup_id, body={"confirm_phrase": phrase}) + ) + create.assert_called_once_with( + "restore-backup", {"backup_id": backup_id}, confirm_phrase=phrase + ) + return reply + + def test_restores_with_the_exact_phrase(self) -> None: + reply = self._restore(f"restore backup {_BACKUP.id}") + assert payload_of(reply) == {"id": "j1"} + + def test_unknown_backup(self) -> None: + with ( + patch(f"{_PKG}.list_backups", return_value=[]), + pytest.raises(ApiError) as err, + ): + _web_jobs.restore_backup_view(make_request("zzz")) + assert err.value.code == "not_found" + + def test_wrong_phrase(self) -> None: + with ( + patch(f"{_PKG}.list_backups", return_value=[_BACKUP]), + pytest.raises(ApiError) as err, + ): + _web_jobs.restore_backup_view(make_request(_BACKUP.id)) + assert err.value.code == "wrong_phrase" + + def test_store_refusal(self) -> None: + phrase = f"restore backup {_BACKUP.id}" + with ( + patch(f"{_PKG}.list_backups", return_value=[_BACKUP]), + patch(f"{_PKG}.create_job", side_effect=JobError("m", code="locked")), + pytest.raises(ApiError) as err, + ): + _web_jobs.restore_backup_view( + make_request(_BACKUP.id, body={"confirm_phrase": phrase}) + ) + assert err.value.code == "locked" diff --git a/steam_backlog_enforcer/tests/test_web_library.py b/steam_backlog_enforcer/tests/test_web_library.py new file mode 100644 index 0000000..da806f8 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_library.py @@ -0,0 +1,191 @@ +"""Tests for _web_library — the owned-games join behind ``GET /api/library``.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _web_library +from steam_backlog_enforcer import config as _config +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._web_request import make_request, payload_of + +_PKG = "steam_backlog_enforcer._web_library" + + +@pytest.fixture(autouse=True) +def _fresh_memos(monkeypatch: pytest.MonkeyPatch) -> None: + """The module keeps process-wide memos; every test starts without them.""" + monkeypatch.setattr(_web_library, "_MEMOS", _web_library._Memos()) + + +def _write_snapshot(rows: list[dict[str, Any]]) -> None: + _config.SNAPSHOT_FILE.write_text(json.dumps(rows), encoding="utf-8") + + +def _row(app_id: int, **extra: object) -> dict[str, Any]: + return {"app_id": app_id, "name": f"G{app_id}", **extra} + + +class TestSnapshotGames: + def test_no_snapshot_file(self) -> None: + assert _web_library._snapshot_games() == {} + + def test_rows_become_games_and_are_memoised(self) -> None: + _write_snapshot( + [ + _row(1, total_achievements=10, unlocked_achievements=4), + {"name": "no id"}, + _row(2, completionist_hours=7.5, playtime_minutes=30), + ] + ) + games = _web_library._snapshot_games() + assert sorted(games) == [1, 2] + assert games[1].total_achievements == 10 + assert games[2].completionist_hours == 7.5 + assert games[1].completionist_hours == -1 + with patch(f"{_PKG}.load_snapshot") as load: + assert _web_library._snapshot_games() is games + load.assert_not_called() + + def test_a_changed_file_is_reparsed(self) -> None: + _write_snapshot([_row(1)]) + assert sorted(_web_library._snapshot_games()) == [1] + _write_snapshot([_row(1), _row(2, name=None)]) + got = _web_library._snapshot_games() + assert sorted(got) == [1, 2] + assert got[2].name == "" + + def test_missing_snapshot_content_is_empty(self) -> None: + _write_snapshot([_row(1)]) + with patch(f"{_PKG}.load_snapshot", return_value=None): + assert _web_library._snapshot_games() == {} + + +class TestOwnedRecords: + def test_cached_records(self) -> None: + records = [{"app_id": 1}, {"name": "no id"}] + with patch(f"{_PKG}.load_owned_records", return_value=records): + got = _web_library._owned_records(Config(steam_id="s")) + assert got == {1: {"app_id": 1}} + + def test_fetched_once_when_nothing_is_cached(self) -> None: + with ( + patch(f"{_PKG}.load_owned_records", return_value=None), + patch(f"{_PKG}.refresh_owned_records", return_value=[{"app_id": 2}]), + ): + assert list(_web_library._owned_records(Config())) == [2] + + def test_steam_unreachable_is_empty(self) -> None: + with ( + patch(f"{_PKG}.load_owned_records", return_value=None), + patch(f"{_PKG}.refresh_owned_records", return_value=None), + ): + assert _web_library._owned_records(Config()) == {} + + +class TestHltbHours: + def test_prefers_the_hltb_cache(self) -> None: + game = GameInfo(1, "g", 1, 0, 0, completionist_hours=9.0) + assert _web_library._hltb_hours(1, game, {1: {"hours": 12.34}}) == 12.3 + + def test_falls_back_to_the_snapshot(self) -> None: + game = GameInfo(1, "g", 1, 0, 0, completionist_hours=9.0) + assert _web_library._hltb_hours(1, game, {}) == 9.0 + + def test_unknown_without_either(self) -> None: + assert _web_library._hltb_hours(1, None, {}) is None + game = GameInfo(1, "g", 1, 0, 0) + assert _web_library._hltb_hours(1, game, {}) is None + + +class TestOwnedAppIds: + def test_union_of_snapshot_and_records_memoised_by_stamp(self) -> None: + _write_snapshot([_row(1)]) + records = [{"app_id": 2}, {"x": 1}] + with ( + patch(f"{_PKG}.owned_cache_stamp", return_value=(1, 1)), + patch(f"{_PKG}.load_owned_records", return_value=records) as load, + ): + assert _web_library.owned_app_ids() == {1, 2} + assert _web_library.owned_app_ids() == {1, 2} + load.assert_called_once() + + def test_a_new_stamp_reloads_and_none_means_empty(self) -> None: + with ( + patch(f"{_PKG}.owned_cache_stamp", side_effect=[(1, 1), (2, 2)]), + patch(f"{_PKG}.load_owned_records", side_effect=[[{"app_id": 5}], None]), + ): + assert _web_library.owned_app_ids() == {5} + assert _web_library.owned_app_ids() == set() + + +class TestReason: + def test_no_achievements_flag(self) -> None: + got = _web_library._reason(1, None, {"has_stats": False}, State(), set()) + assert got == "No achievements" + + def test_not_scanned_yet(self) -> None: + got = _web_library._reason(1, None, {"has_stats": True}, State(), set()) + assert got == "Not scanned yet" + + def test_otherwise_the_pick_rule(self) -> None: + assert _web_library._reason(1, None, {}, State(), set()) == ( + "No achievement data" + ) + game = GameInfo(1, "g", 2, 0, 0) + assert _web_library._reason(1, game, {}, State(), set()) is None + + +class TestLibraryRows: + def test_joins_every_source(self) -> None: + _write_snapshot( + [_row(1, total_achievements=4, unlocked_achievements=1, playtime_minutes=5)] + ) + records = [ + {"app_id": 1, "name": "One", "playtime_minutes": 60, "last_played": 99}, + {"app_id": 2, "has_stats": False}, + ] + with ( + patch(f"{_PKG}.load_owned_records", return_value=records), + patch(f"{_PKG}._read_raw_cache", return_value={1: {"hours": 3.0}}), + patch(f"{_PKG}.get_installed_games", return_value=[(1, "One")]), + patch(f"{_PKG}.allowed_app_ids", return_value={1}), + ): + rows = { + r["app_id"]: r for r in _web_library.library_rows(State(), Config()) + } + assert rows[1] == { + "app_id": 1, + "name": "One", + "achievements_total": 4, + "achievements_unlocked": 1, + "hltb_hours": 3.0, + "playtime_minutes": 60, + "last_played": 99, + "installed": True, + "assigned": True, + "ineligible_reason": None, + } + assert rows[2]["name"] == "2" + assert rows[2]["last_played"] is None + assert rows[2]["ineligible_reason"] == "No achievements" + + def test_snapshot_only_game_uses_its_own_name_and_playtime(self) -> None: + _write_snapshot([_row(3, playtime_minutes=8)]) + with ( + patch(f"{_PKG}.load_owned_records", return_value=[]), + patch(f"{_PKG}._read_raw_cache", return_value={}), + patch(f"{_PKG}.get_installed_games", return_value=[]), + ): + (row,) = _web_library.library_rows(State(), Config()) + assert (row["name"], row["playtime_minutes"]) == ("G3", 8) + + def test_view_wraps_the_rows(self) -> None: + with patch(f"{_PKG}.library_rows", return_value=[{"app_id": 1}]): + reply = _web_library.library_view(make_request()) + assert payload_of(reply) == {"games": [{"app_id": 1}]} diff --git a/steam_backlog_enforcer/tests/test_web_privileged.py b/steam_backlog_enforcer/tests/test_web_privileged.py new file mode 100644 index 0000000..120ae8a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_privileged.py @@ -0,0 +1,97 @@ +"""Tests for _web_privileged — commands the root daemon carries out.""" + +from __future__ import annotations + +from http import HTTPStatus +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _web_privileged +from steam_backlog_enforcer._ctl_protocol import CtlError +from steam_backlog_enforcer._ctl_types import BlockResult, PendingReset, StoreWindow +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer.jobs._view import list_jobs +from steam_backlog_enforcer.tests._web_request import payload_of + +_CLIENT = "steam_backlog_enforcer._web_privileged._ctl_client" + + +class TestLocalHhmm: + @pytest.mark.parametrize("iso", [None, ""]) + def test_missing_is_a_question_mark(self, iso: str | None) -> None: + assert _web_privileged._local_hhmm(iso) == "?" + + def test_unparsable_is_returned_as_is(self) -> None: + assert _web_privileged._local_hhmm("soon") == "soon" + + def test_parsable_is_hh_mm(self) -> None: + got = _web_privileged._local_hhmm("2026-01-01T10:30:00+00:00") + assert len(got) == 5 + assert got[2] == ":" + + +class TestRunPrivileged: + def test_gaming_reset_arms_and_answers_202(self) -> None: + pending = PendingReset("p1", "phrase", "t0", "t1", 5) + with patch(f"{_CLIENT}.gaming_reset_arm", return_value=pending) as arm: + reply = _web_privileged.run_privileged("gaming-reset", {}, None) + arm.assert_called_once_with("") + assert reply.status == HTTPStatus.ACCEPTED + assert payload_of(reply)["id"] == "p1" + assert list_jobs() == [] + + def test_unblock_records_a_job(self) -> None: + window = StoreWindow(10, "2026-01-01T10:30:00+00:00") + with patch(f"{_CLIENT}.store_unblock", return_value=window) as unblock: + reply = _web_privileged.run_privileged("unblock", {"minutes": 10}, "p") + unblock.assert_called_once_with(10, "p") + assert reply.status == HTTPStatus.CREATED + assert "10 min" in payload_of(reply)["summary"] + assert [j["id"] for j in list_jobs()] == [payload_of(reply)["id"]] + + def test_buy_dlc_uses_the_default_window(self) -> None: + window = StoreWindow(_web_privileged.BUY_DLC_MINUTES, "x") + with patch(f"{_CLIENT}.store_unblock", return_value=window) as unblock: + _web_privileged.run_privileged("buy-dlc", {}, None) + unblock.assert_called_once_with(_web_privileged.BUY_DLC_MINUTES, "") + + def test_block_gaming(self) -> None: + with patch(f"{_CLIENT}.block_gaming", return_value=BlockResult(3, None)): + reply = _web_privileged.run_privileged("block-gaming", {"days": 3}, "p") + assert "3 day(s)" in payload_of(reply)["summary"] + + def test_gaming_unblock(self) -> None: + with patch(f"{_CLIENT}.gaming_unblock", return_value=["a", "b"]): + reply = _web_privileged.run_privileged("gaming-unblock", {}, "p") + assert "2 playtime mount(s)" in payload_of(reply)["summary"] + + def test_enforce_restart(self) -> None: + with patch(f"{_CLIENT}.restart", return_value="t") as restart: + reply = _web_privileged.run_privileged("enforce", {"demo": 0}, None) + restart.assert_called_once_with() + assert "restarting" in payload_of(reply)["summary"] + + def test_unknown_command(self) -> None: + with pytest.raises(ApiError) as err: + _web_privileged.run_privileged("scan", {}, None) + assert err.value.code == "unknown_command" + + def test_failed_op_is_recorded_then_raised(self) -> None: + with ( + patch(f"{_CLIENT}.restart", side_effect=CtlError("op_failed", "boom")), + pytest.raises(ApiError) as err, + ): + _web_privileged.run_privileged("enforce", {"demo": 0}, None) + assert err.value.code == "op_failed" + (job,) = list_jobs() + assert (job["summary"], job["exit_code"]) == ("boom", 1) + + def test_refusal_leaves_no_job(self) -> None: + with ( + patch(f"{_CLIENT}.gaming_unblock", side_effect=CtlError("locked", "no")), + pytest.raises(ApiError) as err, + ): + _web_privileged.run_privileged("gaming-unblock", {}, None) + assert err.value.code == "locked" + assert list_jobs() == [] diff --git a/steam_backlog_enforcer/tests/test_web_process.py b/steam_backlog_enforcer/tests/test_web_process.py new file mode 100644 index 0000000..340629a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_process.py @@ -0,0 +1,121 @@ +"""Tests for _web_process — server health, version and stand-down.""" + +from __future__ import annotations + +from http import HTTPStatus +import subprocess +import threading +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _web_process +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer.tests._web_request import make_request, payload_of + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_PKG = "steam_backlog_enforcer._web_process" + + +@pytest.fixture(autouse=True) +def _clean_process_state() -> Iterator[None]: + """Leave neither a retiring flag, a registered server nor a cached sha.""" + _web_process.RETIRING.clear() + _web_process._server.clear() + _web_process.version.cache_clear() + yield + _web_process.RETIRING.clear() + _web_process._server.clear() + _web_process.version.cache_clear() + + +class TestRetire: + def test_without_a_server_it_does_nothing(self) -> None: + _web_process.retire() + assert not _web_process.RETIRING.is_set() + + def test_shuts_the_server_down_once(self) -> None: + stopped = threading.Event() + server = MagicMock() + server.shutdown.side_effect = stopped.set + _web_process.register(server) + _web_process.retire() + assert stopped.wait(timeout=5) + assert _web_process.RETIRING.is_set() + _web_process.retire() + server.shutdown.assert_called_once_with() + + def test_register_replaces_the_previous_server(self) -> None: + first, second = MagicMock(), MagicMock() + _web_process.register(first) + _web_process.register(second) + assert _web_process._server == [second] + + +class TestVersion: + def test_git_short_sha(self) -> None: + done = subprocess.CompletedProcess([], 0, stdout="abc1234\n") + with ( + patch(f"{_PKG}.shutil.which", return_value="/usr/bin/git"), + patch(f"{_PKG}.subprocess.run", return_value=done), + ): + assert _web_process.version() == "abc1234" + + @pytest.mark.parametrize( + "failure", [OSError("x"), subprocess.CalledProcessError(1, "git")] + ) + def test_git_failure_falls_back_to_the_newest_source( + self, failure: Exception, tmp_path: Path + ) -> None: + source = tmp_path / "a.py" + source.write_text("", encoding="utf-8") + with ( + patch(f"{_PKG}.shutil.which", return_value="/usr/bin/git"), + patch(f"{_PKG}.subprocess.run", side_effect=failure), + patch(f"{_PKG}.newest_py_after", return_value=source), + ): + got = _web_process.version() + assert got.endswith("Z") + + def test_no_git_and_no_sources(self) -> None: + with ( + patch(f"{_PKG}.shutil.which", return_value=None), + patch(f"{_PKG}.newest_py_after", return_value=None), + ): + assert _web_process.version() == "unknown" + + +class TestHealth: + @pytest.mark.parametrize(("outdated", "stale"), [(None, False), ("x.py", True)]) + def test_health_payload(self, outdated: str | None, *, stale: bool) -> None: + with ( + patch(f"{_PKG}.outdated_source", return_value=outdated), + patch(f"{_PKG}.version", return_value="v1"), + ): + reply = _web_process.health_view(make_request()) + body = payload_of(reply) + assert (body["stale"], body["version"]) == (stale, "v1") + assert str(body["started_at"]).endswith("Z") + + +class TestRestartView: + def test_refused_without_a_supervisor( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.delenv("INVOCATION_ID", raising=False) + with pytest.raises(ApiError) as err: + _web_process.restart_view(make_request(method="POST")) + assert err.value.code == "unsupported" + + def test_under_systemd_it_retires_after_the_reply( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("INVOCATION_ID", "abc") + with patch(f"{_PKG}.retire") as retire: + reply = _web_process.restart_view(make_request(method="POST")) + retire.assert_called_once_with(_web_process._RESTART_DELAY_SECONDS) + assert reply.status == HTTPStatus.ACCEPTED diff --git a/steam_backlog_enforcer/tests/test_web_routes.py b/steam_backlog_enforcer/tests/test_web_routes.py new file mode 100644 index 0000000..d4ee767 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_routes.py @@ -0,0 +1,41 @@ +"""Tests for _web_routes — the route table and its resolver.""" + +from __future__ import annotations + +import pytest + +from steam_backlog_enforcer import _web_jobs, _web_routes, _web_views + + +class TestResolve: + def test_static_route(self) -> None: + assert _web_routes.resolve("GET", "/api/budget") == ( + _web_views.budget_view, + (), + ) + + def test_captures_the_path_id(self) -> None: + found = _web_routes.resolve("POST", "/api/jobs/j-1_A/cancel") + assert found == (_web_jobs.cancel_view, ("j-1_A",)) + + def test_same_path_resolves_per_method(self) -> None: + assert _web_routes.resolve("GET", "/api/jobs") == (_web_jobs.jobs_view, ()) + assert _web_routes.resolve("POST", "/api/jobs") == ( + _web_jobs.create_job_view, + (), + ) + + def test_known_path_under_another_method(self) -> None: + assert _web_routes.resolve("DELETE", "/api/budget") == "method" + + @pytest.mark.parametrize("path", ["/api/nope", "/api/jobs/a b", "/api/jobs/"]) + def test_unknown_path(self, path: str) -> None: + assert _web_routes.resolve("GET", path) is None + + +class TestEventsPath: + def test_matches_only_the_stream(self) -> None: + match = _web_routes.EVENTS_PATH.fullmatch("/api/jobs/abc/events") + assert match is not None + assert match.groups() == ("abc",) + assert _web_routes.EVENTS_PATH.fullmatch("/api/jobs/abc") is None diff --git a/steam_backlog_enforcer/tests/test_web_server.py b/steam_backlog_enforcer/tests/test_web_server.py index a6d82c6..0613052 100644 --- a/steam_backlog_enforcer/tests/test_web_server.py +++ b/steam_backlog_enforcer/tests/test_web_server.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from pathlib import Path _PKG = "steam_backlog_enforcer._web_server" +_VIEWS_PKG = "steam_backlog_enforcer._web_views" _DATA_PKG = "steam_backlog_enforcer._web_games" # build_web_dataset itself did not move. _DATASET_PKG = "steam_backlog_enforcer._web_dataset" @@ -67,7 +68,9 @@ class TestBudgetEndpoint: def test_budget_ok(self) -> None: with ( - patch(f"{_PKG}.build_budget_snapshot", return_value={"ok": True}) as build, + patch( + f"{_VIEWS_PKG}.build_budget_snapshot", return_value={"ok": True} + ) as build, _running() as port, ): status, body, ctype = _get(port, "/api/budget") @@ -79,7 +82,9 @@ class TestBudgetEndpoint: def test_demo_query_reads_the_demo_run(self) -> None: with ( - patch(f"{_PKG}.build_budget_snapshot", return_value={"ok": True}) as build, + patch( + f"{_VIEWS_PKG}.build_budget_snapshot", return_value={"ok": True} + ) as build, _running() as port, ): _get(port, "/api/budget?demo=1") @@ -87,12 +92,12 @@ class TestBudgetEndpoint: def test_budget_error(self) -> None: with ( - patch(f"{_PKG}.build_budget_snapshot", side_effect=OSError("boom")), + patch(f"{_VIEWS_PKG}.build_budget_snapshot", side_effect=OSError("boom")), _running() as port, ): status, body, _ = _get(port, "/api/budget") assert status == 500 - assert body == b"budget error" + assert json.loads(body)["error"] == "op_failed" class TestDatasetEndpoint: @@ -113,12 +118,12 @@ class TestDatasetEndpoint: def test_dataset_error_returns_500(self) -> None: with ( - patch(f"{_PKG}.build_web_dataset", side_effect=OSError("boom")), + patch(f"{_VIEWS_PKG}.build_web_dataset", side_effect=OSError("boom")), _running() as port, ): status, body, _ = _get(port, "/api/dataset") assert status == 500 - assert b"dataset error" in body + assert json.loads(body)["error"] == "op_failed" class TestStaticServing: diff --git a/steam_backlog_enforcer/tests/test_web_server_routing.py b/steam_backlog_enforcer/tests/test_web_server_routing.py new file mode 100644 index 0000000..f6dac33 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_server_routing.py @@ -0,0 +1,223 @@ +"""Tests for _web_server's router: auth order, stale refusal, dispatch, errors.""" + +from __future__ import annotations + +from contextlib import contextmanager +from http.client import HTTPConnection +import json +import threading +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _web_process, _web_server +from steam_backlog_enforcer._web_io import Reply, Request, ok +from steam_backlog_enforcer.jobs._daemon_jobs import DaemonOutcome, record_daemon_job +from steam_backlog_enforcer.jobs._events import now_iso + +if TYPE_CHECKING: + from collections.abc import Iterator + from contextlib import AbstractContextManager + +_PKG = "steam_backlog_enforcer._web_server" + + +@pytest.fixture(autouse=True) +def _clean_process_state() -> Iterator[None]: + """No retiring flag, and never "stale": other edits to the tree must not leak in.""" + _web_process.RETIRING.clear() + with patch(f"{_PKG}.outdated_source", return_value=None): + yield + _web_process.RETIRING.clear() + _web_process._server.clear() + + +@contextmanager +def _running() -> Iterator[_web_server.WebServer]: + """Serve on an ephemeral loopback port in a thread.""" + server = _web_server.create_server("127.0.0.1", 0) + thread = threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True + ) + thread.start() + try: + yield server + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +def _auth(server: _web_server.WebServer) -> dict[str, str]: + """The same-origin Origin header and the launch token.""" + return { + "Origin": f"http://127.0.0.1:{server.port}", + "X-SBE-Token": server.token, + } + + +def _call( + server: _web_server.WebServer, + method: str, + path: str, + *, + headers: dict[str, str] | None = None, + body: object = None, +) -> tuple[int, bytes, dict[str, str]]: + """One request with *headers* (see :func:`_auth`) and a JSON *body*.""" + sent = dict(headers or {}) + payload = None + if body is not None: + payload = json.dumps(body) + sent["Content-Type"] = "application/json" + conn = HTTPConnection("127.0.0.1", server.port, timeout=5) + try: + conn.request(method, path, body=payload, headers=sent) + resp = conn.getresponse() + return resp.status, resp.read(), dict(resp.getheaders()) + finally: + conn.close() + + +def _error_of(raw: bytes) -> str: + return str(json.loads(raw)["error"]) + + +class TestAuthOrder: + def test_foreign_host_is_refused_even_for_static(self) -> None: + with _running() as server: + status, raw, _ = _call(server, "GET", "/", headers={"Host": "evil:1"}) + assert (status, _error_of(raw)) == (403, "bad_host") + + def test_a_post_needs_the_origin(self) -> None: + with _running() as server: + status, raw, _ = _call(server, "POST", "/api/jobs", body={}) + assert (status, _error_of(raw)) == (403, "bad_origin") + + def test_a_post_needs_the_token(self) -> None: + with _running() as server: + origin = {"Origin": f"http://127.0.0.1:{server.port}"} + status, raw, _ = _call(server, "POST", "/api/jobs", headers=origin) + assert (status, _error_of(raw)) == (403, "bad_token") + + def test_events_need_the_token_in_the_query(self) -> None: + with _running() as server: + status, raw, _ = _call(server, "GET", "/api/jobs/abc/events") + assert (status, _error_of(raw)) == (403, "bad_token") + + +class TestStale: + def _stale(self) -> AbstractContextManager[MagicMock]: + return patch(f"{_PKG}.outdated_source", return_value="x.py") + + def test_api_routes_refuse_and_stand_down(self) -> None: + with self._stale(), patch(f"{_PKG}._web_process.retire") as retire: + with _running() as server: + status, raw, _ = _call(server, "GET", "/api/budget") + assert (status, _error_of(raw)) == (503, "server_stale") + retire.assert_called_once_with() + + def test_static_routes_refuse_in_plain_text(self) -> None: + with ( + self._stale(), + patch(f"{_PKG}._web_process.retire"), + _running() as server, + ): + status, raw, headers = _call(server, "GET", "/") + assert status == 503 + assert raw.decode() == _web_server._STALE_MSG + assert headers["Content-Type"].startswith("text/plain") + + def test_server_health_still_answers(self) -> None: + with ( + self._stale(), + patch(f"{_PKG}._web_process.retire") as retire, + patch("steam_backlog_enforcer._web_process.version", return_value="v1"), + _running() as server, + ): + status, raw, _ = _call(server, "GET", "/api/server") + assert (status, json.loads(raw)["version"]) == (200, "v1") + retire.assert_called_once_with() + + +class TestDispatch: + def test_unknown_endpoint(self) -> None: + with _running() as server: + status, raw, _ = _call(server, "GET", "/api/nope") + assert (status, _error_of(raw)) == (404, "not_found") + + def test_known_path_under_the_wrong_method(self) -> None: + with _running() as server: + status, _, _ = _call(server, "POST", "/api/budget", headers=_auth(server)) + assert status == 405 + + def test_non_get_on_a_static_path(self) -> None: + with _running() as server: + status, _, _ = _call(server, "POST", "/index.html", headers=_auth(server)) + assert status == 405 + + def test_post_body_reaches_the_route(self) -> None: + seen: list[Request] = [] + + def route(request: Request) -> Reply: + seen.append(request) + return ok({"got": request.body}) + + with ( + patch(f"{_PKG}.resolve", return_value=(route, ("a",))), + _running() as server, + ): + status, raw, _ = _call( + server, + "POST", + "/api/jobs/a/x?q=1&q=2", + body={"k": 1}, + headers=_auth(server), + ) + assert (status, json.loads(raw)) == (200, {"got": {"k": 1}}) + assert (seen[0].args, seen[0].query, seen[0].method) == ( + ("a",), + {"q": "1"}, + "POST", + ) + + def test_delete_is_routed(self) -> None: + with ( + patch(f"{_PKG}.resolve", return_value=(lambda _r: ok(1), ())), + _running() as server, + ): + status, _, _ = _call( + server, "DELETE", "/api/pending/a", headers=_auth(server) + ) + assert status == 200 + + def test_a_crashing_route_is_a_500_without_details(self) -> None: + def route(_request: Request) -> Reply: + msg = "secret detail" + raise RuntimeError(msg) + + with ( + patch(f"{_PKG}.resolve", return_value=(route, ())), + _running() as server, + ): + status, raw, _ = _call(server, "GET", "/api/budget") + assert (status, _error_of(raw)) == (500, "op_failed") + assert b"secret" not in raw + + def test_real_route_through_the_table(self) -> None: + with patch(f"{_PKG}.resolve", wraps=_web_server.resolve), _running() as server: + status, raw, _ = _call(server, "GET", "/api/jobs") + assert (status, json.loads(raw)) == (200, []) + + +class TestEvents: + def test_streams_a_finished_job(self) -> None: + done = DaemonOutcome(ok=True, summary="s", exit_code=0) + job_id = record_daemon_job("unblock", {}, created_at=now_iso(), outcome=done) + with _running() as server: + path = f"/api/jobs/{job_id}/events?token={server.token}" + status, raw, headers = _call(server, "GET", path) + assert status == 200 + assert headers["Content-Type"].startswith("text/event-stream") + assert b'"type": "result"' in raw diff --git a/steam_backlog_enforcer/tests/test_web_server_stale.py b/steam_backlog_enforcer/tests/test_web_server_stale.py index ad59178..44510a7 100644 --- a/steam_backlog_enforcer/tests/test_web_server_stale.py +++ b/steam_backlog_enforcer/tests/test_web_server_stale.py @@ -11,7 +11,7 @@ from unittest.mock import patch import pytest -from steam_backlog_enforcer import _web_server +from steam_backlog_enforcer import _web_process from steam_backlog_enforcer.tests.test_web_server import _PKG, _get, _running if TYPE_CHECKING: @@ -23,7 +23,7 @@ class TestStaleServerRefuses: def _clear_retiring(self) -> None: """Reset the module-level stand-down latch between tests.""" - _web_server._RETIRING.clear() + _web_process.RETIRING.clear() @pytest.mark.parametrize("route", ["/api/budget", "/api/dataset", "/"]) def test_every_route_refuses_when_stale(self, tmp_path: Path, route: str) -> None: @@ -59,7 +59,7 @@ class TestStaleServerRefuses: _get(port, "/api/budget") # The 503 is sent before the latch is set, so the client can read # it first: wait rather than race (flaked in CI on 25dc493). - assert _web_server._RETIRING.wait(timeout=5) + assert _web_process.RETIRING.wait(timeout=5) self._clear_retiring() def test_a_second_refusal_does_not_stand_down_twice( @@ -70,7 +70,7 @@ class TestStaleServerRefuses: self._clear_retiring() changed = tmp_path / "changed.py" changed.touch() - _web_server._RETIRING.set() + _web_process.RETIRING.set() with ( patch(f"{_PKG}.outdated_source", return_value=changed), _running() as port, diff --git a/steam_backlog_enforcer/tests/test_web_server_static.py b/steam_backlog_enforcer/tests/test_web_server_static.py new file mode 100644 index 0000000..8722752 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_server_static.py @@ -0,0 +1,150 @@ +"""Tests for _web_server's static bundle, token injection and ``serve``.""" + +from __future__ import annotations + +from http.client import HTTPConnection +import threading +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _web_auth, _web_process, _web_server + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_STUB = "tok" +_PKG = "steam_backlog_enforcer._web_server" + + +@pytest.fixture(autouse=True) +def _clean_process_state() -> Iterator[None]: + """No retiring flag, and never "stale": other edits to the tree must not leak in.""" + _web_process.RETIRING.clear() + with patch(f"{_PKG}.outdated_source", return_value=None): + yield + _web_process.RETIRING.clear() + _web_process._server.clear() + + +@pytest.fixture +def dist(tmp_path: Path) -> Iterator[Path]: + """A fake built bundle, served instead of ``web/dist``.""" + root = (tmp_path / "dist").resolve() + root.mkdir() + (root / "index.html").write_text("INDEX", "utf-8") + (root / "app.js").write_text("console.log(1)", "utf-8") + (root / "logo.png").write_bytes(b"\x89PNG") + (root / "noext").write_text("plain", "utf-8") + sub = root / "sub" + sub.mkdir() + (sub / "index.html").write_text("SUB", "utf-8") + (tmp_path / "secret.txt").write_text("top secret", "utf-8") + with patch(f"{_PKG}.WEB_DIST", root): + yield root + + +def _get(path: str) -> tuple[int, bytes, str, str, str]: + """GET *path* from a throw-away server; the token is the last element.""" + server = _web_server.create_server("127.0.0.1", 0) + thread = threading.Thread( + target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True + ) + thread.start() + conn = HTTPConnection("127.0.0.1", server.port, timeout=5) + try: + conn.request("GET", path) + resp = conn.getresponse() + return ( + resp.status, + resp.read(), + resp.headers.get("Content-Type", ""), + resp.headers.get("Cache-Control", ""), + server.token, + ) + finally: + conn.close() + server.shutdown() + server.server_close() + thread.join(timeout=2) + + +@pytest.mark.usefixtures("dist") +class TestStatic: + def test_index_carries_this_launchs_token_and_is_not_cached(self) -> None: + status, body, ctype, cache, token = _get("/") + assert status == 200 + assert f'content="{token}"'.encode() in body + assert ctype == "text/html; charset=utf-8" + assert cache == "no-store" + + def test_javascript_declares_its_charset(self) -> None: + _, body, ctype, cache, _ = _get("/app.js") + assert body == b"console.log(1)" + assert ctype.endswith("; charset=utf-8") + assert cache == "" + + def test_binary_has_no_charset(self) -> None: + _, body, ctype, _, _ = _get("/logo.png") + assert (body, ctype) == (b"\x89PNG", "image/png") + + 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"SUB", "") + + 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_path_traversal_gets_the_index_not_the_file(self) -> None: + status, body, _, _, _ = _get("/..%2Fsecret.txt") + assert status == 200 + assert b"top secret" not in body + + 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("/") + assert (status, body) == (404, _web_server._NOT_BUILT_MSG) + + +class TestServer: + def test_port_is_the_bound_one(self) -> None: + server = _web_server.create_server("127.0.0.1", 0) + try: + assert server.port > 0 + assert len(server.token) == 64 + assert _web_process._server == [server] + finally: + server.server_close() + + def test_query_keeps_the_first_value(self) -> None: + assert _web_server._query("a=1&a=2&b=3") == {"a": "1", "b": "3"} + + +class TestServe: + def _serve(self, serve_forever: object) -> MagicMock: + server = MagicMock() + server.token = _STUB + server.port = 4321 + server.serve_forever = serve_forever + with ( + patch(f"{_PKG}.create_server", return_value=server), + patch(f"{_PKG}._echo") as echo, + ): + _web_server.serve("127.0.0.1", 4321) + server.server_close.assert_called_once_with() + return echo + + def test_writes_the_token_file_and_runs_until_it_returns(self) -> None: + echo = self._serve(MagicMock()) + assert _web_auth.token_path(4321).read_text("ascii") == "tok\n" + assert "Press Ctrl-C" in echo.call_args_list[2].args[0] + + def test_ctrl_c_shuts_down_cleanly(self) -> None: + echo = self._serve(MagicMock(side_effect=KeyboardInterrupt)) + assert "Shutting down" in echo.call_args.args[0] diff --git a/steam_backlog_enforcer/tests/test_web_setup.py b/steam_backlog_enforcer/tests/test_web_setup.py new file mode 100644 index 0000000..12f1c6d --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_setup.py @@ -0,0 +1,160 @@ +"""Tests for _web_setup — credentials are saved only after Steam accepts them.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from steam_backlog_enforcer import _web_setup +from steam_backlog_enforcer import config as _config +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer.tests._web_request import make_request, payload_of + +if TYPE_CHECKING: + from steam_backlog_enforcer._web_io import Request + +_PKG = "steam_backlog_enforcer._web_setup" +_KEY = "0123456789abcdef0123456789ABCDEF" +_ID = "76561190000000001" + + +def _steam(status: int = 200, body: object = None) -> MagicMock: + resp = MagicMock() + resp.status_code = status + if isinstance(body, Exception): + resp.json.side_effect = body + else: + resp.json.return_value = body + return resp + + +def _players(*names: str) -> dict[str, Any]: + return {"response": {"players": [{"personaname": n} for n in names]}} + + +class TestField: + def test_returns_the_stripped_value(self) -> None: + request = make_request(body={"steam_id": f" {_ID} "}) + got = _web_setup._field(request, "steam_id", _web_setup._STEAM_ID, "an id") + assert got == _ID + + @pytest.mark.parametrize("raw", [None, 5, "", "abc"]) + def test_rejects_missing_or_malformed(self, raw: object) -> None: + request = make_request(body={"steam_id": raw}) + with pytest.raises(ApiError) as err: + _web_setup._field(request, "steam_id", _web_setup._STEAM_ID, "an id") + assert err.value.code == "invalid_params" + + +class TestVerifyWithSteam: + def test_accepts_a_known_account(self) -> None: + with patch(f"{_PKG}.requests.get", return_value=_steam(200, _players("x"))): + _web_setup.verify_with_steam(_KEY, _ID) + + def test_network_failure_is_op_failed(self) -> None: + with ( + patch(f"{_PKG}.requests.get", side_effect=requests.Timeout), + pytest.raises(ApiError) as err, + ): + _web_setup.verify_with_steam(_KEY, _ID) + assert err.value.code == "op_failed" + assert "Timeout" in err.value.message + + @pytest.mark.parametrize("status", [401, 403]) + def test_rejected_key(self, status: int) -> None: + with ( + patch(f"{_PKG}.requests.get", return_value=_steam(status)), + pytest.raises(ApiError) as err, + ): + _web_setup.verify_with_steam(_KEY, _ID) + assert (err.value.code, err.value.message) == ( + "invalid_params", + "Steam rejected this API key.", + ) + + def test_other_http_status(self) -> None: + with ( + patch(f"{_PKG}.requests.get", return_value=_steam(500)), + pytest.raises(ApiError) as err, + ): + _web_setup.verify_with_steam(_KEY, _ID) + assert err.value.code == "op_failed" + + @pytest.mark.parametrize( + "reply", + [_steam(200, ValueError()), _steam(200, {}), _steam(200, {"response": None})], + ) + def test_unexpected_reply_shape(self, reply: MagicMock) -> None: + with ( + patch(f"{_PKG}.requests.get", return_value=reply), + pytest.raises(ApiError) as err, + ): + _web_setup.verify_with_steam(_KEY, _ID) + assert err.value.code == "op_failed" + + def test_unknown_account(self) -> None: + with ( + patch(f"{_PKG}.requests.get", return_value=_steam(200, _players())), + pytest.raises(ApiError) as err, + ): + _web_setup.verify_with_steam(_KEY, _ID) + assert err.value.code == "invalid_params" + + +class TestSaveSetup: + _LOCK = "steam_backlog_enforcer._web_setup.lock_reason" + + def _request(self, **body: str) -> Request: + return make_request( + body={"steam_api_key": _KEY, "steam_id": _ID, **body}, method="POST" + ) + + def test_bad_key_is_refused_first(self) -> None: + with pytest.raises(ApiError) as err: + _web_setup.save_setup(self._request(steam_api_key="short")) + assert "steam_api_key" in err.value.message + + def test_bad_steam_id_is_refused(self) -> None: + with pytest.raises(ApiError) as err: + _web_setup.save_setup(self._request(steam_id="1")) + assert "steam_id" in err.value.message + + def test_a_locked_setup_is_refused_before_asking_steam(self) -> None: + with ( + patch(self._LOCK, return_value="Total block"), + patch(f"{_PKG}.verify_with_steam") as verify, + pytest.raises(ApiError) as err, + ): + _web_setup.save_setup(self._request()) + assert err.value.code == "locked" + verify.assert_not_called() + + def test_steam_refusal_saves_nothing(self) -> None: + with ( + patch(self._LOCK, return_value=None), + patch( + f"{_PKG}.verify_with_steam", + side_effect=ApiError("no", code="invalid_params"), + ), + patch(f"{_PKG}.save_credentials") as save, + pytest.raises(ApiError), + ): + _web_setup.save_setup(self._request()) + save.assert_not_called() + + def test_saves_and_never_echoes_the_key(self) -> None: + with ( + patch(self._LOCK, return_value=None), + patch(f"{_PKG}.verify_with_steam"), + ): + reply = _web_setup.save_setup(self._request()) + assert payload_of(reply) == { + "configured": True, + "has_api_key": True, + "steam_id": _ID, + } + assert _KEY in _config.CONFIG_FILE.read_text(encoding="utf-8") + assert _KEY not in str(reply.payload) diff --git a/steam_backlog_enforcer/tests/test_web_sse.py b/steam_backlog_enforcer/tests/test_web_sse.py new file mode 100644 index 0000000..77b108e --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_sse.py @@ -0,0 +1,209 @@ +"""Tests for _web_sse — the job event stream.""" + +from __future__ import annotations + +from http import HTTPStatus +import io +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _web_process, _web_sse +from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer.jobs._daemon_jobs import DaemonOutcome, record_daemon_job +from steam_backlog_enforcer.jobs._events import now_iso +from steam_backlog_enforcer.tests._web_request import make_request + +if TYPE_CHECKING: + from collections.abc import Buffer, Iterator + from http.server import BaseHTTPRequestHandler + +_PKG = "steam_backlog_enforcer._web_sse" +_RESULT = {"seq": 1, "type": "result"} +_DONE = {"seq": 2, "type": "state", "state": "succeeded"} + + +class _Handler: + def __init__(self, wfile: io.BytesIO | None = None) -> None: + self.wfile = wfile or io.BytesIO() + self.headers_sent: list[tuple[str, str]] = [] + self.status: int | None = None + self.close_connection = False + + def send_response(self, status: int) -> None: + self.status = status + + def send_header(self, name: str, value: str) -> None: + self.headers_sent.append((name, value)) + + def end_headers(self) -> None: + return + + def as_handler(self) -> BaseHTTPRequestHandler: + return cast("BaseHTTPRequestHandler", self) + + def written(self) -> str: + return self.wfile.getvalue().decode() + + +class _Clock: + """A fake monotonic clock that ``sleep`` advances, then retires the server.""" + + def __init__(self, step: float, sleeps_before_retire: int) -> None: + self.now = 0.0 + self.step = step + self.left = sleeps_before_retire + + def monotonic(self) -> float: + return self.now + + def sleep(self, _seconds: float) -> None: + self.now += self.step + self.left -= 1 + if self.left <= 0: + _web_process.RETIRING.set() + + +@pytest.fixture(autouse=True) +def _not_retiring() -> Iterator[None]: + _web_process.RETIRING.clear() + yield + _web_process.RETIRING.clear() + + +def _pump(clock: _Clock, events: list[list[dict[str, Any]]], state: str) -> str: + """Run ``_pump`` over scripted event batches (then empty ones).""" + handler = _Handler() + batches = iter(events) + with ( + patch(f"{_PKG}.time.monotonic", clock.monotonic), + patch(f"{_PKG}.time.sleep", clock.sleep), + patch(f"{_PKG}.read_job_events", side_effect=lambda *_: next(batches, [])), + patch(f"{_PKG}.read_job", return_value={"state": state}), + ): + _web_sse._pump(handler.as_handler(), "j", 0) + return handler.written() + + +class TestResumeAfter: + @pytest.mark.parametrize( + ("headers", "query", "expected"), + [ + ({}, {}, 0), + ({}, {"after": "7"}, 7), + ({"Last-Event-ID": "9"}, {"after": "7"}, 9), + ({}, {"after": "-3"}, 0), + ], + ) + def test_header_wins_over_query( + self, headers: dict[str, str], query: dict[str, str], expected: int + ) -> None: + request = make_request(headers=headers, query=query) + assert _web_sse.resume_after(request) == expected + + def test_non_numeric(self) -> None: + with pytest.raises(ApiError) as err: + _web_sse.resume_after(make_request(query={"after": "x"})) + assert err.value.code == "invalid_params" + + +class TestFrames: + def test_frame_layout(self) -> None: + assert _web_sse._frame({"seq": 4, "a": 1}) == ( + b'id: 4\ndata: {"seq": 4, "a": 1}\n\n' + ) + + @pytest.mark.parametrize( + ("event", "terminal"), + [ + ({"type": "state", "state": "failed"}, True), + ({"type": "state", "state": "running"}, False), + ({"type": "result", "state": "failed"}, False), + ], + ) + def test_terminal_state(self, event: dict[str, str], *, terminal: bool) -> None: + assert _web_sse._is_terminal_state(event) is terminal + + +class TestPump: + def test_stops_at_once_when_retiring(self) -> None: + _web_process.RETIRING.set() + assert _pump(_Clock(1, 99), [[_RESULT]], "running") == "" + + def test_ends_after_the_final_state(self) -> None: + out = _pump(_Clock(1, 99), [[{"seq": 0, "type": "log"}, _RESULT, _DONE]], "x") + assert out.count("data:") == 3 + assert out.endswith("\n\n") + + def test_ends_a_grace_after_a_result_without_a_state(self) -> None: + clock = _Clock(3, 99) + out = _pump(clock, [[_RESULT]], "running") + assert out.count("data:") == 1 + assert clock.left == 98 # one sleep, then the grace lapsed + + def test_ends_for_a_finished_job_with_nothing_new(self) -> None: + assert _pump(_Clock(1, 99), [], "failed") == "" + + def test_keeps_alive_while_waiting(self) -> None: + out = _pump(_Clock(10, 3), [], "running") + assert out == ": keep-alive\n\n" + + def test_keeps_waiting_inside_the_grace_period(self) -> None: + clock = _Clock(1, 3) + out = _pump(clock, [[_RESULT]], "running") + assert out.count("data:") == 1 + assert _web_process.RETIRING.is_set() + + +class TestStream: + def _job(self) -> str: + done = DaemonOutcome(ok=True, summary="done", exit_code=0) + return record_daemon_job("unblock", {}, created_at=now_iso(), outcome=done) + + def test_streams_a_finished_job_and_closes(self) -> None: + job_id = self._job() + handler = _Handler() + _web_sse.stream_job_events(handler.as_handler(), make_request(job_id)) + assert handler.status == HTTPStatus.OK + assert handler.close_connection is True + assert ("Content-Type", "text/event-stream; charset=utf-8") in ( + handler.headers_sent + ) + out = handler.written() + assert out.startswith("retry: 2000\n\n") + assert '"type": "result"' in out + assert '"state": "succeeded"' in out + + def test_resumes_past_the_end(self) -> None: + job_id = self._job() + handler = _Handler() + request = make_request(job_id, query={"after": "999"}) + _web_sse.stream_job_events(handler.as_handler(), request) + assert handler.written() == "retry: 2000\n\n" + + def test_unknown_job_sends_nothing(self) -> None: + handler = _Handler() + with pytest.raises(ApiError) as err: + _web_sse.stream_job_events(handler.as_handler(), make_request("nope")) + assert err.value.code == "not_found" + assert handler.status is None + + def test_bad_after_is_refused_before_any_byte(self) -> None: + handler = _Handler() + with pytest.raises(ApiError): + _web_sse.stream_job_events( + handler.as_handler(), make_request("j", query={"after": "x"}) + ) + assert handler.status is None + + @pytest.mark.parametrize("gone", [BrokenPipeError, ConnectionResetError]) + def test_a_vanished_browser_is_not_an_error(self, gone: type[OSError]) -> None: + class _Dead(io.BytesIO): + def write(self, _data: Buffer) -> int: + raise gone + + handler = _Handler(_Dead()) + with patch(f"{_PKG}.read_job", return_value={"id": "j"}): + _web_sse.stream_job_events(handler.as_handler(), make_request("j")) + assert handler.status == HTTPStatus.OK diff --git a/steam_backlog_enforcer/tests/test_web_views.py b/steam_backlog_enforcer/tests/test_web_views.py new file mode 100644 index 0000000..fa0cd55 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_web_views.py @@ -0,0 +1,120 @@ +"""Tests for _web_views — the read-only API projections.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _steam_state, _web_views +from steam_backlog_enforcer._backups import StateBackup +from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.tests._web_request import make_request, payload_of + +_PKG = "steam_backlog_enforcer._web_views" + + +class TestStatus: + def test_manual_pick_fields(self) -> None: + full = _web_views._manual_pick( + {"app_id": 1, "game_name": "One", "age_days": 2.345} + ) + assert full == {"app_id": 1, "name": "One", "age_days": 2.3} + + def test_unknown_name_and_age(self) -> None: + bare = _web_views._manual_pick({"app_id": 2, "age_days": None}) + assert bare == {"app_id": 2, "name": "2", "age_days": 0.0} + + def test_status_view_reshapes_the_payload(self) -> None: + raw = { + "assigned_game_installed": None, + "manual_picks": [{"app_id": 3, "game_name": "G", "age_days": 1.0}], + "other": "kept", + } + with patch(f"{_PKG}.status_payload", return_value=raw): + body = payload_of(_web_views.status_view(make_request())) + assert body["assigned_game_installed"] is False + assert body["manual_picks"] == [{"app_id": 3, "name": "G", "age_days": 1.0}] + assert body["other"] == "kept" + + +class TestDatasetViews: + def test_stats_projects_two_keys(self) -> None: + full = {"default_summary": {"a": 1}, "pace_vs_hltb": [2], "games": []} + with ( + patch(f"{_PKG}.build_web_dataset"), + patch(f"{_PKG}.dataset_to_payload", return_value=full), + ): + reply = _web_views.stats_view(make_request()) + assert reply.payload == {"default_summary": {"a": 1}, "pace_vs_hltb": [2]} + + def test_dataset_view(self) -> None: + with ( + patch(f"{_PKG}.build_web_dataset"), + patch(f"{_PKG}.dataset_to_payload", return_value={"games": []}), + ): + assert _web_views.dataset_view(make_request()).payload == {"games": []} + + @pytest.mark.parametrize(("query", "demo"), [({}, False), ({"demo": "1"}, True)]) + def test_budget_view(self, query: dict[str, str], *, demo: bool) -> None: + with patch(f"{_PKG}.build_budget_snapshot", return_value={"x": 1}) as build: + reply = _web_views.budget_view(make_request(query=query)) + build.assert_called_once_with(demo=demo) + assert reply.payload == {"x": 1} + + +class TestInstalled: + def test_size_read_from_the_manifest(self) -> None: + manifest = _steam_state.STEAMAPPS_PATH / "appmanifest_5.acf" + manifest.write_text('"AppState"\n{\n\t"SizeOnDisk"\t\t"1234"\n}', "utf-8") + assert _web_views._size_on_disk(5) == 1234 + + def test_size_unknown_without_a_match_or_a_file(self) -> None: + manifest = _steam_state.STEAMAPPS_PATH / "appmanifest_6.acf" + manifest.write_text('"AppState" {}', encoding="utf-8") + assert _web_views._size_on_disk(6) == 0 + assert _web_views._size_on_disk(7) == 0 + + def test_installed_view(self) -> None: + with ( + patch(f"{_PKG}.get_installed_games", return_value=[(5, "Five")]), + patch(f"{_PKG}.allowed_app_ids", return_value={5}), + patch(f"{_PKG}.is_protected_app", return_value=False), + patch(f"{_PKG}._size_on_disk", return_value=9), + ): + reply = _web_views.installed_view(make_request()) + assert payload_of(reply) == { + "games": [ + { + "app_id": 5, + "name": "Five", + "size_bytes": 9, + "assigned": True, + "protected": False, + } + ] + } + + +class TestSimpleViews: + def test_commands(self) -> None: + with patch(f"{_PKG}.command_specs", return_value=[{"name": "scan"}]): + assert _web_views.commands_view(make_request()).payload == [ + {"name": "scan"} + ] + + def test_setup_status_never_carries_the_key(self) -> None: + unset = _web_views.setup_status(Config()) + assert unset == {"configured": False, "has_api_key": False, "steam_id": None} + done = _web_views.setup_status(Config(steam_api_key="k", steam_id="7")) + assert done == {"configured": True, "has_api_key": True, "steam_id": "7"} + + def test_setup_view(self) -> None: + body = payload_of(_web_views.setup_view(make_request())) + assert body["configured"] is False + + def test_backups(self) -> None: + backup = StateBackup("id1", "t", "r", 4) + with patch(f"{_PKG}.list_backups", return_value=[backup]): + reply = _web_views.backups_view(make_request()) + assert reply.payload == [backup.to_json()] diff --git a/web/eslint.config.js b/web/eslint.config.js index cd66985..9d2d2a5 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -12,6 +12,7 @@ export default defineConfig([ extends: [ js.configs.recommended, tseslint.configs.recommended, + // `recommended` includes the React Compiler rules since plugin v6. reactHooks.configs.flat.recommended, reactRefresh.configs.vite, ], @@ -19,4 +20,11 @@ export default defineConfig([ globals: globals.browser, }, }, + { + // 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, + }, + }, ]) diff --git a/web/index.html b/web/index.html index 84b2cb2..db20107 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - Steam Backlog Enforcer — Completion Planner + Steam Backlog Enforcer
diff --git a/web/mock/budget.ts b/web/mock/budget.ts new file mode 100644 index 0000000..1647343 --- /dev/null +++ b/web/mock/budget.ts @@ -0,0 +1,85 @@ +// `/api/budget` fixture: today's usage ticks up in real time so the budget +// cards visibly move while the mock runs. + +import type { BudgetSnapshot } from '../src/types.ts' +import { hash } from './games.ts' + +const LEGEND = [ + { key: 'app:1145360', label: 'Hades' }, + { key: 'app:367520', label: 'Hollow Knight' }, + { key: 'app:2379780', label: 'Balatro' }, + { key: 'launcher:steam', label: 'Steam (launcher)' }, + { key: 'other', label: 'Other' }, +] + +const started = Date.now() +let resetAt: number | null = null + +export function resetToday(): void { + resetAt = Date.now() +} + +export function budget(demo: boolean): BudgetSnapshot { + const budgetSeconds = demo ? 60 : 3 * 3600 + const base = demo ? 20 : 2 * 3600 + 10 * 60 + const since = resetAt ?? started + const used = Math.min(budgetSeconds, (resetAt === null ? base : 0) + Math.floor((Date.now() - since) / 1000)) + const remaining = budgetSeconds - used + const today = new Date() + const history = Array.from({ length: 14 }, (_, i) => { + const d = new Date(today.getTime() - (13 - i) * 86_400_000) + const h = hash(i + 7) + const segs = LEGEND.map((l, j) => ({ key: l.key, seconds: j === 0 ? 1800 + (h % 5400) : (h >>> (j * 3)) % 2400 })) + return { + day: d.toISOString().slice(0, 10), + seconds: segs.reduce((a, s) => a + s.seconds, 0), + segments: segs, + } + }) + const slices = [ + { key: 'app:1145360', label: 'Hades', seconds: Math.round(used * 0.82), fraction: 0.82 }, + { key: 'launcher:steam', label: 'Steam (launcher)', seconds: Math.round(used * 0.18), fraction: 0.18 }, + ] + return { + ok: true, + readable: true, + state_status: 'ok', + error: null, + today: { + gaming_day: today.toISOString().slice(0, 10), + day_starts_at: '06:00 local', + seconds_used: used, + budget_seconds: budgetSeconds, + seconds_remaining: remaining, + fraction_used: used / budgetSeconds, + blocked: remaining <= 0, + blocked_at: remaining <= 0 ? Math.floor(Date.now() / 1000) : 0, + next_warning_seconds: remaining > 1800 ? 1800 : remaining > 600 ? 600 : null, + warned_seconds: remaining <= 3600 ? [3600] : [], + games: used > 0 ? slices : [], + }, + session: { + available: true, + observed_at: new Date().toISOString(), + state: remaining > 0 ? 'engaged' : 'blocked', + game_name: 'Hades', + billing_label: 'Hades', + qualifying_count: 2, + processes: [ + { pid: 48211, name: 'Hades.exe' }, + { pid: 48190, name: 'steam' }, + ], + }, + history, + legend: LEGEND, + rules: { + budget_seconds: budgetSeconds, + enforcement: true, + counts_launchers: true, + warn_at: [3600, 1800, 600, 300], + demo, + masked_launchers: ['heroic', 'lutris'], + carry_seconds: demo ? 0 : 1200, + }, + } +} diff --git a/web/mock/catalog.ts b/web/mock/catalog.ts new file mode 100644 index 0000000..673ddc0 --- /dev/null +++ b/web/mock/catalog.ts @@ -0,0 +1,117 @@ +// `GET /api/commands` for the mock: the server-driven catalog, mirroring +// `steam_backlog_enforcer/_friction.py` and the CLI's lock-exempt sets. + +import type { CommandName, CommandSpec, ParamSpec } from '../src/api/contract.ts' +import { world } from './world.ts' + +type Base = Omit + +const APP_ID: ParamSpec = { name: 'app_id', label: 'Game', type: 'app_id', required: true } +const MINUTES: ParamSpec = { + name: 'minutes', label: 'Minutes', type: 'int', required: true, min: 1, max: 30, default: 15, + help: 'Hard cap: 30 minutes.', +} + +function spec( + name: CommandName, + description: string, + category: CommandSpec['category'], + kind: CommandSpec['kind'], + extra: Partial = {}, +): Base { + return { + name, description, category, kind, params: [], friction: null, + privileged: false, mutating: kind === 'job', cancellable: false, ...extra, + } +} + +const phrase = (phrase_template: string, countdown_seconds?: number) => + countdown_seconds ? { phrase_template, countdown_seconds } : { phrase_template } + +export const BASE: Base[] = [ + spec('status', 'Show current status', 'backlog', 'view', { view_endpoint: '/api/status' }), + spec('list', 'List games from snapshot', 'backlog', 'view', { view_endpoint: '/api/dataset' }), + spec('stats', 'Show backlog completion-time estimates', 'backlog', 'view', { view_endpoint: '/api/stats' }), + spec('installed', 'List installed games', 'library', 'view', { view_endpoint: '/api/installed' }), + spec('gaming-status', "Show today's gaming time and block state", 'gaming', 'view', { + view_endpoint: '/api/budget', + }), + spec('check', 'Check assigned game for a new achievement', 'backlog', 'job', { mutating: false }), + spec('scan', 'Scan library & assign a game', 'backlog', 'job', { cancellable: true }), + spec('done', 'Move on after a new achievement, pick next', 'backlog', 'job'), + spec('pick', 'Manually pick your next game from candidates', 'backlog', 'job', { cancellable: true }), + spec('pick-manual', 'Pick a game by app_id, lock enforcer for 14 days', 'backlog', 'job', { + params: [APP_ID], friction: phrase('lock in {game_name}'), + }), + spec('abandon-pick', 'Undo a manual pick at any time', 'backlog', 'job', { + params: [APP_ID], friction: phrase('abandon {game_name}'), + }), + spec('install', 'Install the assigned game', 'library', 'job', { cancellable: true }), + spec('uninstall', 'Uninstall all non-assigned games', 'library', 'job', { + friction: phrase('uninstall {count} games'), + }), + spec('hide', 'Hide all non-assigned games in library', 'library', 'job'), + spec('unhide', 'Unhide all games in library', 'library', 'job'), + spec('buy-dlc', 'Unblock the store to buy a game/DLC', 'store', 'job', { + params: [MINUTES], friction: phrase('unblock the store for {minutes} minutes'), privileged: true, + }), + spec('unblock', 'Unblock the store for a few minutes', 'store', 'job', { + params: [MINUTES], friction: phrase('unblock the store for {minutes} minutes'), privileged: true, + }), + spec('add-exception', 'Request a 24h-locked whitelist exception', 'store', 'job', { + params: [APP_ID, { name: 'reason', label: 'Reason', type: 'string', required: true, help: 'Why this game must be allowed.' }], + friction: phrase('request exception for {game_name}'), + }), + spec('block-gaming', 'Block ALL gaming for N days, no in-app undo', 'gaming', 'job', { + params: [{ name: 'days', label: 'Days', type: 'int', required: true, min: 1, max: 365, default: 1 }], + friction: phrase('block all gaming for {days} days'), privileged: true, + }), + spec('reset', 'Reset all state (a timestamped backup is taken first)', 'system', 'job', { + friction: phrase('wipe all enforcer state'), + }), + spec('setup', 'First-time setup: Steam API key and Steam ID', 'system', 'screen'), + spec('enforce', 'Restart the enforcer daemon, or run a 60 s demo budget', 'system', 'job', { + params: [{ + name: 'mode', label: 'Mode', type: 'string', required: true, default: 'restart', + help: '"restart" (rate-limited: once per 10 min) or "demo" (60 s budget).', + }], + privileged: true, + }), + spec('gaming-reset', "Reset today's gaming counter", 'gaming', 'job', { + friction: phrase("reset today's gaming budget", 300), privileged: true, + }), + spec('gaming-unblock', 'Force-release playtime bind mounts (recovery hatch)', 'gaming', 'job', { + friction: phrase('force release playtime mounts'), privileged: true, + }), + spec('serve', 'The web UI server: health, stale check and restart', 'system', 'screen'), +] + +const MANUAL_EXEMPT = new Set([ + 'done', 'check', 'status', 'enforce', 'setup', 'serve', 'abandon-pick', 'pick-manual', + 'gaming-status', 'gaming-unblock', 'gaming-reset', +]) +const TOTAL_EXEMPT = new Set(['status', 'enforce', 'gaming-status', 'gaming-unblock']) + +export function lockedReason(name: CommandName): string | null { + if (world.lock === 'total' && !TOTAL_EXEMPT.has(name)) { + return 'Total gaming block is active (4 days left). Nothing can shorten it.' + } + if (world.lock === 'manual' && !MANUAL_EXEMPT.has(name)) { + return 'Manual pick lock: earn one new achievement in Hades, then run "done" or "check".' + } + return null +} + +export function catalog(): CommandSpec[] { + const countdown = world.countdownSeconds + return BASE.map((s) => ({ + ...s, + friction: + s.friction?.countdown_seconds !== undefined ? { ...s.friction, countdown_seconds: countdown } : s.friction, + locked_reason: lockedReason(s.name), + })) +} + +export function findSpec(name: string): CommandSpec | undefined { + return catalog().find((s) => s.name === name) +} diff --git a/web/mock/games.ts b/web/mock/games.ts new file mode 100644 index 0000000..257d9d2 --- /dev/null +++ b/web/mock/games.ts @@ -0,0 +1,96 @@ +// Real-looking backlog for the dev-only mock API. Hours are derived from a +// stable hash so every reload shows the same numbers. + +import type { InstalledGame } from '../src/api/contract.ts' +import type { WebGame } from '../src/types.ts' + +export const GAMES: ReadonlyArray = [ + [1145360, 'Hades'], + [367520, 'Hollow Knight'], + [504230, 'Celeste'], + [588650, 'Dead Cells'], + [413150, 'Stardew Valley'], + [646570, 'Slay the Spire'], + [590380, 'Into the Breach'], + [632470, 'Disco Elysium'], + [753640, 'Outer Wilds'], + [653530, 'Return of the Obra Dinn'], + [1092790, 'Inscryption'], + [268910, 'Cuphead'], + [1057090, 'Ori and the Will of the Wisps'], + [264710, 'Subnautica'], + [620, 'Portal 2'], + [220, 'Half-Life 2'], + [460950, 'Katana ZERO'], + [219150, 'Hotline Miami'], + [391540, 'Undertale'], + [736260, 'Baba Is You'], + [553420, 'TUNIC'], + [972660, 'Spiritfarer'], + [1055540, 'A Short Hike'], + [383870, 'Firewatch'], + [501300, 'What Remains of Edith Finch'], + [683320, 'GRIS'], + [304430, 'INSIDE'], + [262060, 'Darkest Dungeon'], + [212680, 'FTL: Faster Than Light'], + [1794680, 'Vampire Survivors'], + [2379780, 'Balatro'], + [1313140, 'Cult of the Lamb'], + [894020, "Death's Door"], + [1205520, 'Pentiment'], + [1578650, 'Citizen Sleeper'], + [1262350, 'SIGNALIS'], + [1451940, 'NEEDY GIRL OVERDOSE'], + [1533420, 'Neon White'], + [2231450, 'Pizza Tower'], + [1868140, 'DAVE THE DIVER'], + [1244090, 'Sea of Stars'], + [1229240, 'Chained Echoes'], +] + +const TIERS = ['platinum', 'gold', 'gold', 'silver', 'platinum', 'bronze', 'borked', 'pending'] + +/** Small deterministic hash so fixture numbers are stable across reloads. */ +export function hash(n: number): number { + let x = n ^ 0x5bd1e995 + x = Math.imul(x ^ (x >>> 15), 0x2c1b3c6d) + x = Math.imul(x ^ (x >>> 12), 0x297a2d39) + return (x ^ (x >>> 15)) >>> 0 +} + +export function nameOf(appId: number): string | null { + return GAMES.find(([id]) => id === appId)?.[1] ?? null +} + +export function makeWebGame(appId: number, name: string): WebGame { + const h = hash(appId) + const rush = 4 + (h % 60) + const noData = h % 13 === 0 + return { + app_id: appId, + name, + completion_pct: h % 7 === 0 ? (h % 80) + 5 : 0, + playtime_minutes: h % 3 === 0 ? (h % 900) : 0, + rush_hours: noData ? -1 : rush, + leisure_hours: noData ? -1 : Math.round(rush * 1.8), + worst_hours: noData ? -1 : Math.round(rush * 2.4), + count_comp: noData ? 0 : 5 + (h % 70), + comp_100_count: noData ? 0 : 1 + (h % 12), + hltb_game_id: noData ? 0 : 10000 + (h % 90000), + protondb_tier: TIERS[h % TIERS.length], + protondb_trending_tier: TIERS[(h >>> 3) % TIERS.length], + protondb_score: (h % 100) / 100, + } +} + +export function makeInstalled(assignedId: number): InstalledGame[] { + const ids = [assignedId, 367520, 620, 413150, 1794680, 2379780] + return ids.map((id) => ({ + app_id: id, + name: nameOf(id) ?? `App ${id}`, + size_bytes: (hash(id) % 40_000 + 300) * 1024 * 1024, + assigned: id === assignedId, + protected: id === 620, + })) +} diff --git a/web/mock/http.ts b/web/mock/http.ts new file mode 100644 index 0000000..60c593f --- /dev/null +++ b/web/mock/http.ts @@ -0,0 +1,83 @@ +// Small HTTP helpers shared by the mock route handlers. + +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { CommandSpec, ParamSpec } from '../src/api/contract.ts' +import type { ApiErrorCode } from '../src/api/jobContract.ts' +import { nameOf } from './games.ts' +import { gameName, world } from './world.ts' + +export class HttpError extends Error { + readonly status: number + readonly code: ApiErrorCode + constructor(status: number, code: ApiErrorCode, message: string) { + super(message) + this.status = status + this.code = code + } +} + +export function send(res: ServerResponse, status: number, body?: unknown): void { + res.statusCode = status + if (body === undefined) { + res.end() + return + } + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify(body)) +} + +export async function readJson(req: IncomingMessage): Promise> { + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const text = Buffer.concat(chunks).toString('utf8') + if (!text) return {} + try { + return JSON.parse(text) as Record + } catch { + throw new HttpError(400, 'invalid_params', 'Body is not valid JSON.') + } +} + +/** Mirror of `_friction.phrase_matches`: only surrounding whitespace is forgiven. */ +export function phraseMatches(expected: string, typed: unknown): boolean { + return typeof typed === 'string' && typed.trim() === expected +} + +export function fillPhrase(template: string, params: Record, extra: Record = {}): string { + const fields: Record = { + count: String(world.installed.filter((g) => !g.assigned && !g.protected).length), + ...Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)])), + ...extra, + } + if (params.app_id !== undefined) fields.game_name = gameName(Number(params.app_id)) + return template.replace(/\{(\w+)\}/g, (_, key: string) => fields[key] ?? `{${key}}`) +} + +function checkParam(p: ParamSpec, raw: unknown): number | string | undefined { + if (raw === undefined || raw === '') { + if (p.required && p.default === undefined) throw new HttpError(400, 'invalid_params', `${p.label} is required.`) + return p.default + } + if (p.type === 'string') return String(raw) + const n = Number(raw) + if (!Number.isInteger(n)) throw new HttpError(400, 'invalid_params', `${p.label} must be a whole number.`) + if (p.min !== undefined && n < p.min) throw new HttpError(400, 'invalid_params', `${p.label} must be at least ${p.min}.`) + if (p.max !== undefined && n > p.max) throw new HttpError(400, 'invalid_params', `${p.label} must be at most ${p.max}.`) + if (p.type === 'app_id' && nameOf(n) === null) { + throw new HttpError(400, 'invalid_params', `App ${n} is not in your library.`) + } + return n +} + +export function validateParams(spec: CommandSpec, raw: unknown): Record { + const input = (raw && typeof raw === 'object' ? raw : {}) as Record + const out: Record = {} + for (const p of spec.params) { + const v = checkParam(p, input[p.name]) + if (v !== undefined) out[p.name] = v + } + if (spec.name === 'enforce' && out.mode !== 'restart' && out.mode !== 'demo') { + throw new HttpError(400, 'invalid_params', 'Mode must be "restart" or "demo".') + } + return out +} diff --git a/web/mock/jobs.ts b/web/mock/jobs.ts new file mode 100644 index 0000000..48c84df --- /dev/null +++ b/web/mock/jobs.ts @@ -0,0 +1,152 @@ +// In-memory job engine for the mock: jobs emit JobEvents over time, block on +// prompts until `POST …/answer` arrives, and keep their history so a page +// reload (or SSE reconnect with `after=`) replays exactly what was missed. + +import { randomUUID } from 'node:crypto' +import type { CommandName } from '../src/api/contract.ts' +import type { Job, JobEvent, JobState, PromptKind, PromptOption } from '../src/api/jobContract.ts' + +type Listener = (e: JobEvent) => void +type EventBody = JobEvent extends infer E ? (E extends JobEvent ? Omit : never) : never + +export interface MockJob { + job: Job + events: JobEvent[] + listeners: Set + waiting: Map void> + cancelled: boolean + mutating: boolean +} + +export class Cancelled extends Error {} + +export interface JobCtx { + params: Record + log(message: string, level?: 'info' | 'warning' | 'error'): void + progress(step: number, total: number | null, label: string, item?: string, eta?: number): void + prompt( + kind: PromptKind, + message: string, + extra?: { options?: PromptOption[]; phrase?: string; default?: string; app_ids?: number[] }, + ): Promise + sleep(ms: number): Promise +} + +export type Script = (ctx: JobCtx) => Promise<{ summary: string; ok?: boolean; data?: unknown }> + +const TERMINAL: ReadonlySet = new Set(['succeeded', 'failed', 'cancelled']) +export const jobs = new Map() + +export function isTerminal(state: JobState): boolean { + return TERMINAL.has(state) +} + +export function runningMutating(): MockJob | undefined { + return [...jobs.values()].find((j) => j.mutating && !isTerminal(j.job.state)) +} + +function emit(mj: MockJob, body: EventBody): void { + const event = { ...body, seq: mj.events.length + 1, ts: new Date().toISOString() } as JobEvent + mj.events.push(event) + if (event.type === 'state') { + mj.job.state = event.state + if (event.state === 'running' && mj.job.started_at === null) mj.job.started_at = event.ts + if (isTerminal(event.state)) mj.job.ended_at = event.ts + } else if (event.type === 'progress') { + const { step, total, label, item, eta_seconds } = event + mj.job.progress = { step, total, label, item, eta_seconds } + } else if (event.type === 'result') { + mj.job.summary = event.summary + mj.job.exit_code = event.ok ? 0 : 1 + } + for (const l of mj.listeners) l(event) +} + +export function startJob( + command: CommandName, + params: Record, + script: Script, + mutating: boolean, +): Job { + const job: Job = { + id: randomUUID().slice(0, 8), + command, + params, + state: 'queued', + created_at: new Date().toISOString(), + started_at: null, + ended_at: null, + exit_code: null, + progress: null, + summary: null, + } + const mj: MockJob = { job, events: [], listeners: new Set(), waiting: new Map(), cancelled: false, mutating } + jobs.set(job.id, mj) + emit(mj, { type: 'state', state: 'queued' }) + void run(mj, script) + return { ...job } +} + +async function run(mj: MockJob, script: Script): Promise { + const sleep = (ms: number) => + new Promise((resolve, reject) => { + setTimeout(() => (mj.cancelled ? reject(new Cancelled()) : resolve()), ms) + }) + let n = 0 + const ctx: JobCtx = { + params: mj.job.params, + log: (message, level = 'info') => emit(mj, { type: 'log', level, message }), + progress: (step, total, label, item, eta) => + emit(mj, { type: 'progress', step, total, label, item, eta_seconds: eta }), + prompt: (kind, message, extra = {}) => { + n += 1 + const prompt_id = `p${n}` + emit(mj, { type: 'state', state: 'waiting_input' }) + emit(mj, { type: 'prompt', prompt_id, kind, message, ...extra }) + return new Promise((resolve) => { + mj.waiting.set(prompt_id, (value) => { + mj.waiting.delete(prompt_id) + emit(mj, { type: 'state', state: 'running' }) + resolve(value) + }) + }) + }, + sleep, + } + try { + await sleep(400) + emit(mj, { type: 'state', state: 'running' }) + const { summary, ok = true, data } = await script(ctx) + emit(mj, { type: 'result', ok, summary, data }) + emit(mj, { type: 'state', state: ok ? 'succeeded' : 'failed' }) + } catch (e) { + if (e instanceof Cancelled) { + emit(mj, { type: 'log', level: 'warning', message: 'Cancelled by user.' }) + emit(mj, { type: 'result', ok: false, summary: 'Cancelled before it finished.' }) + emit(mj, { type: 'state', state: 'cancelled' }) + } else { + emit(mj, { type: 'log', level: 'error', message: String(e) }) + emit(mj, { type: 'result', ok: false, summary: e instanceof Error ? e.message : String(e) }) + emit(mj, { type: 'state', state: 'failed' }) + } + } +} + +export function answer(id: string, promptId: string, value: string): boolean { + const resolve = jobs.get(id)?.waiting.get(promptId) + if (!resolve) return false + resolve(value) + return true +} + +export function cancel(id: string): void { + const mj = jobs.get(id) + if (!mj || isTerminal(mj.job.state)) return + mj.cancelled = true + // A job blocked on a prompt has no sleep to wake; release it. + for (const resolve of [...mj.waiting.values()]) resolve('') +} + +export function listJobs(): Job[] { + return [...jobs.values()].map((j) => ({ ...j.job })).reverse().slice(0, 50) +} diff --git a/web/mock/pending.ts b/web/mock/pending.ts new file mode 100644 index 0000000..26b1634 --- /dev/null +++ b/web/mock/pending.ts @@ -0,0 +1,62 @@ +// Two-phase countdown actions (gaming-reset): arm → heartbeats → commit. +// Lapses when a heartbeat is missed by more than 15 s, like the daemon does. + +import { randomUUID } from 'node:crypto' +import type { CommandName } from '../src/api/contract.ts' +import type { PendingAction } from '../src/api/jobContract.ts' +import { HttpError } from './http.ts' + +const HEARTBEAT_SECONDS = 10 +const GRACE_MS = 15_000 + +interface Armed { + action: PendingAction + lastBeat: number +} + +const pending = new Map() + +export function arm(command: CommandName, phrase: string, countdownSeconds: number): PendingAction { + for (const p of pending.values()) { + if (p.action.command === command) throw new HttpError(409, 'countdown_running', 'A countdown for this command is already armed.') + } + const now = Date.now() + const action: PendingAction = { + id: randomUUID().slice(0, 8), + command, + phrase, + armed_at: new Date(now).toISOString(), + ready_at: new Date(now + countdownSeconds * 1000).toISOString(), + heartbeat_interval_seconds: HEARTBEAT_SECONDS, + } + pending.set(action.id, { action, lastBeat: now }) + return action +} + +function live(id: string): Armed { + const p = pending.get(id) + if (!p) throw new HttpError(410, 'pending_lapsed', 'This countdown is gone (lapsed or cancelled). Arm it again.') + if (Date.now() - p.lastBeat > HEARTBEAT_SECONDS * 1000 + GRACE_MS) { + pending.delete(id) + throw new HttpError(410, 'pending_lapsed', 'A heartbeat was missed, so the countdown lapsed. Arm it again.') + } + return p +} + +export function heartbeat(id: string): PendingAction { + const p = live(id) + p.lastBeat = Date.now() + return p.action +} + +export function take(id: string): PendingAction { + const p = live(id) + if (Date.now() < Date.parse(p.action.ready_at)) { + throw new HttpError(409, 'countdown_running', 'The countdown has not finished yet.') + } + return p.action +} + +export function drop(id: string): void { + pending.delete(id) +} diff --git a/web/mock/plugin.ts b/web/mock/plugin.ts new file mode 100644 index 0000000..e2c162d --- /dev/null +++ b/web/mock/plugin.ts @@ -0,0 +1,27 @@ +// 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. + +import { randomBytes } from 'node:crypto' +import type { Plugin } from 'vite' +import { TOKEN_META } from '../src/api/contract.ts' +import { handle } from './routes.ts' + +export function mockApi(): Plugin { + // Per-launch token, like the real server's web-.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' }] + }, + configureServer(server) { + server.middlewares.use((req, res, next) => { + handle(req, res, token).then((handled) => { + if (!handled) next() + }, next) + }) + }, + } +} diff --git a/web/mock/routes.ts b/web/mock/routes.ts new file mode 100644 index 0000000..e5b9466 --- /dev/null +++ b/web/mock/routes.ts @@ -0,0 +1,169 @@ +// Route table for the mock API. Behaviour follows DOCS-web-control-api.md: +// token on every non-GET, `busy`/`locked`/`wrong_phrase`/`daemon_unreachable` +// /`not_found`/`server_stale` errors, 201 Job vs 202 PendingAction (the +// phrase is checked at arm and again at commit), SSE with `after=` resume. + +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { CommandName } from '../src/api/contract.ts' +import type { JobRequest } from '../src/api/jobContract.ts' +import { budget } from './budget.ts' +import { catalog, findSpec } from './catalog.ts' +import { HttpError, fillPhrase, phraseMatches, readJson, send, validateParams } from './http.ts' +import { answer, cancel, jobs, listJobs, runningMutating, startJob } from './jobs.ts' +import { arm, drop, heartbeat, take } from './pending.ts' +import { SCRIPTS, gamingReset, restoreBackup } from './scripts.ts' +import { streamEvents } from './sse.ts' +import { daemonStatus, dataset, library, serverHealth, setupStatus, stats, status, world } from './world.ts' + +type Ctx = { req: IncomingMessage; res: ServerResponse; url: URL; m: RegExpMatchArray } +type Handler = (ctx: Ctx) => unknown | Promise +const routes: [string, RegExp, Handler][] = [] +const on = (method: string, path: RegExp, h: Handler) => routes.push([method, path, h]) + +const RESTORE_PHRASE = 'restore backup {backup_id}' + +on('GET', /^\/api\/dataset$/, () => dataset()) +on('GET', /^\/api\/budget$/, ({ url }) => budget(url.searchParams.get('demo') === '1')) +on('GET', /^\/api\/status$/, () => status()) +on('GET', /^\/api\/stats$/, () => stats()) +on('GET', /^\/api\/installed$/, () => ({ games: world.installed })) +on('GET', /^\/api\/library$/, () => library()) +// Offline: no covers, so every card shows its title tile. +on('GET', /^\/api\/art\/(\d+)$/, () => { + throw new HttpError(404, 'not_found', 'No cover art in the mock.') +}) +on('GET', /^\/api\/commands$/, () => catalog()) +on('GET', /^\/api\/setup$/, () => setupStatus()) +on('POST', /^\/api\/setup$/, async ({ req }) => { + const body = await readJson(req) + const key = String(body.steam_api_key ?? '') + const id = String(body.steam_id ?? '') + if (!/^[0-9A-F]{32}$/i.test(key)) throw new HttpError(400, 'invalid_params', 'Steam rejected the API key (expect 32 hex characters).') + if (!/^7656\d{13}$/.test(id)) throw new HttpError(400, 'invalid_params', 'Steam ID must be the 17-digit SteamID64 (starts with 7656).') + world.configured = true + world.steamId = id + return setupStatus() +}) +on('GET', /^\/api\/daemon$/, () => daemonStatus()) +on('GET', /^\/api\/server$/, () => serverHealth()) +on('POST', /^\/api\/server\/restart$/, ({ res }) => { + setTimeout(() => { + world.serverStartedAt = Date.now() + world.stale = false + }, 2500) + send(res, 202) +}) +on('GET', /^\/api\/backups$/, () => world.backups) +on('POST', /^\/api\/backups\/([^/]+)\/restore$/, async ({ req, res, m }) => { + const id = decodeURIComponent(m[1]) + if (!world.backups.some((b) => b.id === id)) throw new HttpError(404, 'not_found', `No backup ${id}.`) + const body = await readJson(req) + if (!phraseMatches(fillPhrase(RESTORE_PHRASE, {}, { backup_id: id }), body.confirm_phrase)) { + throw new HttpError(400, 'wrong_phrase', 'The phrase does not match.') + } + guardBusy() + send(res, 201, startJob('restore-backup', { backup_id: id }, restoreBackup(id), true)) +}) +on('GET', /^\/api\/jobs$/, () => listJobs()) +on('POST', /^\/api\/jobs$/, async ({ req, res }) => { + const body = (await readJson(req)) as Partial + const spec = findSpec(String(body.command)) + if (!spec || spec.kind !== 'job') throw new HttpError(400, 'unknown_command', `Unknown command "${body.command}".`) + if (spec.locked_reason) throw new HttpError(409, 'locked', spec.locked_reason) + const params = validateParams(spec, body.params) + if (spec.privileged && world.daemon === 'unreachable') { + throw new HttpError(503, 'daemon_unreachable', 'The root daemon is not answering on /run/steam-backlog-enforcer/ctl.sock.') + } + if (spec.name === 'enforce' && params.mode === 'restart' && world.restartAvailableAt && world.restartAvailableAt > Date.now()) { + throw new HttpError(429, 'rate_limited', 'The daemon was restarted less than 10 minutes ago.') + } + if (spec.friction) { + const expected = fillPhrase(spec.friction.phrase_template, params) + if (!phraseMatches(expected, body.confirm_phrase)) throw new HttpError(400, 'wrong_phrase', 'The phrase does not match.') + if (spec.friction.countdown_seconds) { + send(res, 202, arm(spec.name, expected, spec.friction.countdown_seconds)) + return + } + } + if (spec.mutating) guardBusy() + const script = SCRIPTS[spec.name as CommandName] + if (!script) throw new HttpError(400, 'unknown_command', `No runner for "${spec.name}".`) + send(res, 201, startJob(spec.name, params, script, spec.mutating)) +}) +on('GET', /^\/api\/jobs\/([^/]+)$/, ({ m }) => job(m[1]).job) +on('GET', /^\/api\/jobs\/([^/]+)\/events$/, ({ req, res, url, m }) => { + streamEvents(req, res, job(m[1]), Number(url.searchParams.get('after') ?? 0)) +}) +on('POST', /^\/api\/jobs\/([^/]+)\/answer$/, async ({ req, res, m }) => { + const body = await readJson(req) + if (!answer(job(m[1]).job.id, String(body.prompt_id), String(body.value ?? ''))) { + throw new HttpError(400, 'invalid_params', 'That prompt is not waiting for an answer.') + } + send(res, 204) +}) +on('POST', /^\/api\/jobs\/([^/]+)\/cancel$/, ({ res, m }) => { + const mj = job(m[1]) + if (!findSpec(mj.job.command)?.cancellable) throw new HttpError(409, 'not_cancellable', 'This command cannot be cancelled once started.') + cancel(mj.job.id) + send(res, 204) +}) +on('POST', /^\/api\/pending\/([^/]+)\/heartbeat$/, ({ m }) => heartbeat(m[1])) +on('POST', /^\/api\/pending\/([^/]+)\/commit$/, async ({ req, res, m }) => { + const action = take(m[1]) + const body = await readJson(req) + if (!phraseMatches(action.phrase, body.confirm_phrase)) throw new HttpError(400, 'wrong_phrase', 'The phrase does not match.') + guardBusy() + drop(action.id) + send(res, 201, startJob(action.command, {}, gamingReset, true)) +}) +on('DELETE', /^\/api\/pending\/([^/]+)$/, ({ res, m }) => { + drop(m[1]) + send(res, 204) +}) +// Dev-only scenario switch for screenshots: lock modes, dead daemon, short countdown. +on('POST', /^\/__mock\/scenario$/, async ({ req }) => { + const body = await readJson(req) + Object.assign(world, body) + return { ok: true } +}) + +function job(id: string) { + const mj = jobs.get(id) + if (!mj) throw new HttpError(404, 'not_found', `No job ${id}.`) + return mj +} + +function guardBusy(): void { + const busy = runningMutating() + if (busy) throw new HttpError(409, 'busy', `"${busy.job.command}" is still running; wait for it to finish.`) +} + +export async function handle(req: IncomingMessage, res: ServerResponse, token: string): Promise { + const url = new URL(req.url ?? '/', 'http://mock') + if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/__mock/')) return false + try { + // Like the real server retiring on outdated code: every /api/* refuses + // except the health route (and the mock's restart, which clears it). + if (world.stale && url.pathname.startsWith('/api/') && !/^\/api\/server(\/restart)?$/.test(url.pathname)) { + throw new HttpError(503, 'server_stale', 'This server is running outdated code and is restarting on current code.') + } + const isStream = url.pathname.endsWith('/events') + const given = isStream ? url.searchParams.get('token') : req.headers['x-sbe-token'] + if ((req.method !== 'GET' || isStream) && given !== token) { + throw new HttpError(403, 'bad_token', 'Missing or wrong session token.') + } + for (const [method, path, h] of routes) { + const m = url.pathname.match(path) + if (m && method === req.method) { + const out = await h({ req, res, url, m }) + if (out !== undefined) send(res, 200, out) + return true + } + } + throw new HttpError(404, 'not_found', `No such endpoint: ${req.method} ${url.pathname}`) + } catch (e) { + if (e instanceof HttpError) send(res, e.status, { error: e.code, message: e.message }) + else send(res, 500, { error: 'invalid_params', message: String(e) }) + return true + } +} diff --git a/web/mock/scripts.ts b/web/mock/scripts.ts new file mode 100644 index 0000000..367b1bb --- /dev/null +++ b/web/mock/scripts.ts @@ -0,0 +1,194 @@ +// Fake job bodies, one per command. They pace themselves like the real CLI +// (HLTB lookups are slow, ProtonDB fast) so the progress UI has something to show. + +import type { CommandName } from '../src/api/contract.ts' +import { resetToday } from './budget.ts' +import { GAMES, makeInstalled, makeWebGame } from './games.ts' +import type { JobCtx, Script } from './jobs.ts' +import { gameName, journal, library, world } from './world.ts' + +const sample = (n: number) => GAMES.filter(([id]) => !world.finished.includes(id)).slice(0, n) + +async function walk(ctx: JobCtx, label: string, items: string[], ms: number): Promise { + for (const [i, item] of items.entries()) { + ctx.progress(i + 1, items.length, label, item, Math.round(((items.length - i - 1) * ms) / 1000)) + await ctx.sleep(ms) + } +} + +const scan: Script = async (ctx) => { + ctx.progress(0, null, 'Fetching owned games from Steam') + ctx.log('GET IPlayerService/GetOwnedGames → 512 apps') + await ctx.sleep(2000) + const games = sample(36).map(([, n]) => n) + await walk(ctx, 'Fetching HLTB times', games, 450) + ctx.log('HLTB: 33 matched, 3 without data (kept as -1)', 'warning') + await walk(ctx, 'Reading ProtonDB tiers', games, 90) + ctx.progress(1, 1, 'Assigning the shortest qualifying game') + await ctx.sleep(600) + ctx.log(`Assigned: ${gameName(world.currentAppId ?? 1145360)} (unchanged)`) + return { summary: `Scanned 512 apps, 36 candidates; assignment unchanged (${gameName(world.currentAppId ?? 0)}).` } +} + +const check: Script = async (ctx) => { + const name = gameName(world.currentAppId ?? 0) + ctx.progress(1, 2, 'Fetching achievements', name) + await ctx.sleep(1200) + ctx.progress(2, 2, 'Comparing with last snapshot', name) + await ctx.sleep(600) + ctx.log('41 of 49 achievements, no new ones since 2026-10-08') + return { summary: `No new achievement in ${name} yet.` } +} + +const done: Script = async (ctx) => { + const name = gameName(world.currentAppId ?? 0) + ctx.progress(1, 3, 'Checking for a new achievement', name) + await ctx.sleep(1200) + ctx.log(`New achievement found in ${name}: "Is There Anybody Out There?"`) + const yes = await ctx.prompt('confirm', `Mark ${name} finished and assign the next game?`, { default: 'yes' }) + if (yes !== 'yes') return { summary: `Kept ${name} assigned.` } + ctx.progress(2, 3, 'Refreshing HLTB times for candidates') + await ctx.sleep(1500) + const next = sample(3).find(([id]) => id !== world.currentAppId)! + if (world.currentAppId !== null) world.finished.push(world.currentAppId) + world.currentAppId = next[0] + world.installed = makeInstalled(next[0]) + ctx.progress(3, 3, 'Assigning next game', next[1]) + await ctx.sleep(500) + return { summary: `${name} done. Next up: ${next[1]}.` } +} + +const pick: Script = async (ctx) => { + ctx.progress(1, 2, 'Ranking candidates') + await ctx.sleep(1500) + const options = sample(6).map(([id, n]) => { + const g = makeWebGame(id, n) + return { value: String(id), label: n, detail: `${g.leisure_hours > 0 ? g.leisure_hours : '?'} h leisure · ProtonDB ${g.protondb_tier}` } + }) + options.push({ value: 'own', label: 'Pick my own game…', detail: 'Any owned game you have not 100%-completed' }) + let chosen = 'own' + while (chosen === 'own') { + chosen = await ctx.prompt('choice', 'Pick your next game:', { options }) + if (chosen !== 'own') break + const appIds = library().games.filter((g) => g.ineligible_reason === null).map((g) => g.app_id) + // An empty answer goes back to the list, like the real job. + chosen = (await ctx.prompt('game', 'Pick your next game', { app_ids: appIds })) || 'own' + } + ctx.progress(2, 2, 'Assigning', gameName(Number(chosen))) + await ctx.sleep(700) + world.currentAppId = Number(chosen) + return { summary: `Assigned ${gameName(Number(chosen))}.` } +} + +const install: Script = async (ctx) => { + const name = gameName(world.currentAppId ?? 0) + const dir = await ctx.prompt('text', 'Steam library folder to install into:', { default: '/mnt/games/SteamLibrary' }) + ctx.log(`steam://install/${world.currentAppId} → ${dir || '/mnt/games/SteamLibrary'}`) + for (let i = 1; i <= 20; i += 1) { + ctx.progress(i, 20, 'Downloading', `${name} — ${(i * 0.6).toFixed(1)} of 12.0 GB`, (20 - i) * 0.4) + await ctx.sleep(400) + } + return { summary: `${name} installed.` } +} + +const uninstall: Script = async (ctx) => { + const victims = world.installed.filter((g) => !g.assigned && !g.protected) + const typed = await ctx.prompt('phrase', '2 of these games have local saves that are not in Steam Cloud.', { + phrase: 'delete unsynced saves', + }) + if (typed.trim() !== 'delete unsynced saves') throw new Error('Phrase did not match; nothing was uninstalled.') + await walk(ctx, 'Uninstalling', victims.map((g) => g.name), 700) + world.installed = world.installed.filter((g) => g.assigned || g.protected) + return { summary: `Uninstalled ${victims.length} games.` } +} + +const simple = (label: string, summary: string, steps = 5): Script => async (ctx) => { + await walk(ctx, label, Array.from({ length: steps }, (_, i) => `batch ${i + 1}`), 500) + return { summary } +} + +const unblock: Script = async (ctx) => { + const minutes = Number(ctx.params.minutes) + ctx.progress(1, 1, 'Asking the daemon to lift the store block') + await ctx.sleep(900) + world.storeBlockedUntil = Date.now() + minutes * 60_000 + journal(`store unblocked for ${minutes} min (web)`) + return { summary: `Store unblocked for ${minutes} minutes.` } +} + +const enforce: Script = async (ctx) => { + if (ctx.params.mode === 'demo') { + await walk(ctx, 'Demo enforcement (60 s budget)', ['arm', 'tick', 'tick', 'warn', 'cutoff'], 900) + return { summary: 'Demo run finished: cutoff engaged after 60 s.' } + } + ctx.progress(1, 3, 'Flushing state') + await ctx.sleep(700) + ctx.progress(2, 3, 'Restarting daemon') + world.daemon = 'restarting' + journal('restart requested over ctl.sock (state flushed)') + await ctx.sleep(2500) + world.daemon = 'running' + world.daemonStartedAt = Date.now() + world.restartAvailableAt = Date.now() + 10 * 60_000 + journal('enforce loop started (tick 3s)') + ctx.progress(3, 3, 'Waiting for first tick') + await ctx.sleep(600) + return { summary: 'Daemon restarted. Next restart possible in 10 minutes.' } +} + +export const gamingReset: Script = async (ctx) => { + ctx.progress(1, 1, 'Committing reset over ctl.sock') + await ctx.sleep(800) + resetToday() + journal('gaming budget reset by user (logged to ledger)') + return { summary: "Today's gaming counter reset to 0." } +} + +export const restoreBackup = (id: string): Script => async (ctx) => { + await walk(ctx, 'Restoring backup', ['state.json', 'snapshot.json', 'hltb_cache.json'], 600) + return { summary: `Restored backup ${id}.` } +} + +export const SCRIPTS: Partial> = { + scan, + check, + done, + pick, + install, + uninstall, + unblock, + 'buy-dlc': unblock, + enforce, + hide: simple('Hiding non-assigned games', 'Hid 511 games; 1 visible.'), + unhide: simple('Unhiding games', 'All 512 games visible again.'), + 'block-gaming': async (ctx) => { + ctx.progress(1, 1, 'Installing total block') + await ctx.sleep(1200) + world.totalBlockUntil = Date.now() + Number(ctx.params.days) * 86_400_000 + world.lock = 'total' + return { summary: `All gaming blocked for ${ctx.params.days} days.` } + }, + 'gaming-unblock': simple('Releasing bind mounts', 'Released 2 playtime bind mounts.', 2), + 'pick-manual': async (ctx) => { + await ctx.sleep(800) + world.manualPicks.push({ app_id: Number(ctx.params.app_id), picked_at: Date.now() }) + return { summary: `Locked in ${gameName(Number(ctx.params.app_id))} for 14 days.` } + }, + 'abandon-pick': async (ctx) => { + await ctx.sleep(800) + world.manualPicks = world.manualPicks.filter((p) => p.app_id !== Number(ctx.params.app_id)) + return { summary: `Abandoned ${gameName(Number(ctx.params.app_id))}.` } + }, + 'add-exception': async (ctx) => { + await ctx.sleep(800) + return { summary: `Exception for ${gameName(Number(ctx.params.app_id))} requested; usable in 24 h.` } + }, + reset: async (ctx) => { + ctx.progress(1, 2, 'Writing backup') + await ctx.sleep(900) + world.backups.unshift({ id: new Date().toISOString().replace(/\D/g, '').slice(0, 14), created_at: new Date().toISOString(), reason: 'before reset', size_bytes: 48_000 }) + ctx.progress(2, 2, 'Wiping state') + await ctx.sleep(900) + return { summary: 'State wiped; backup saved.' } + }, +} diff --git a/web/mock/sse.ts b/web/mock/sse.ts new file mode 100644 index 0000000..bd4127a --- /dev/null +++ b/web/mock/sse.ts @@ -0,0 +1,33 @@ +// Server-sent events for one job: replay everything after `after`, then +// follow live events, and close the stream once the job is terminal. + +import type { IncomingMessage, ServerResponse } from 'node:http' +import type { JobEvent } from '../src/api/jobContract.ts' +import { isTerminal, type MockJob } from './jobs.ts' + +const frame = (e: JobEvent) => `id: ${e.seq}\ndata: ${JSON.stringify(e)}\n\n` + +export function streamEvents(req: IncomingMessage, res: ServerResponse, mj: MockJob, after: number): void { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + for (const e of mj.events) if (e.seq > after) res.write(frame(e)) + if (isTerminal(mj.job.state)) { + res.end() + return + } + const keepalive = setInterval(() => res.write(': keepalive\n\n'), 15_000) + const listener = (e: JobEvent) => { + res.write(frame(e)) + if (e.type === 'state' && isTerminal(e.state)) stop() + } + const stop = () => { + clearInterval(keepalive) + mj.listeners.delete(listener) + res.end() + } + mj.listeners.add(listener) + req.on('close', stop) +} diff --git a/web/mock/world.ts b/web/mock/world.ts new file mode 100644 index 0000000..d90c34e --- /dev/null +++ b/web/mock/world.ts @@ -0,0 +1,209 @@ +// Mutable world state behind the mock API. Jobs mutate it, so the UI sees +// the effect of `done`, `uninstall`, `unblock` … after the job finishes. + +import type { + DaemonState, + DaemonStatus, + InstalledGame, + LibraryPayload, + ServerHealth, + SetupStatus, + StateBackup, + StatsPayload, + StatusPayload, +} from '../src/api/contract.ts' +import type { WebDataset } from '../src/types.ts' +import { GAMES, hash, makeInstalled, makeWebGame, nameOf } from './games.ts' + +export type LockMode = 'none' | 'manual' | 'total' + +const iso = (ms: number) => new Date(ms).toISOString() +const DAY = 86_400_000 + +export interface World { + configured: boolean + steamId: string | null + lock: LockMode + daemon: DaemonState + daemonStartedAt: number + restartAvailableAt: number | null + serverStartedAt: number + stale: boolean + countdownSeconds: number + currentAppId: number | null + finished: number[] + storeBlockedUntil: number | null + totalBlockUntil: number | null + manualPicks: { app_id: number; picked_at: number }[] + installed: InstalledGame[] + backups: StateBackup[] + journal: string[] +} + +const now = Date.now() + +export const world: World = { + configured: true, + steamId: '76561198012345678', + lock: 'none', + daemon: 'running', + daemonStartedAt: now - 3 * 3_600_000, + restartAvailableAt: null, + serverStartedAt: now - 40 * 60_000, + stale: false, + countdownSeconds: 300, + currentAppId: 1145360, + finished: [504230, 391540, 1055540, 383870, 501300, 683320, 304430], + storeBlockedUntil: null, + totalBlockUntil: null, + manualPicks: [], + installed: makeInstalled(1145360), + backups: [ + { id: '20261009-221504', created_at: iso(now - DAY), reason: 'before reset', size_bytes: 48_211 }, + { id: '20261002-090112', created_at: iso(now - 8 * DAY), reason: 'nightly', size_bytes: 47_930 }, + { id: '20260925-090044', created_at: iso(now - 15 * DAY), reason: 'nightly', size_bytes: 45_118 }, + ], + journal: [], +} + +export function journal(line: string): void { + const stamp = new Date().toTimeString().slice(0, 8) + world.journal.push(`${stamp} steam-backlog-enforcer[4123]: ${line}`) + if (world.journal.length > 200) world.journal.splice(0, world.journal.length - 200) +} + +for (const line of [ + 'enforce loop started (tick 3s)', + 'store blocked via iptables (4 rules)', + 'library hidden: 512 apps, 1 visible (Hades)', + 'playtime: Hades engaged, 2h10m of 3h used', +]) { + journal(line) +} + +export function gameName(appId: number): string { + return nameOf(appId) ?? `App ${appId}` +} + +export function status(): StatusPayload { + const t = Date.now() + const totalActive = world.totalBlockUntil !== null && world.totalBlockUntil > t + return { + current_app_id: world.currentAppId, + current_game_name: world.currentAppId === null ? null : gameName(world.currentAppId), + finished_count: world.finished.length, + store_blocked: world.storeBlockedUntil === null || world.storeBlockedUntil < t, + installed_count: world.installed.length, + assigned_game_installed: world.installed.some((g) => g.assigned), + total_block: { + active: totalActive, + days_remaining: totalActive ? Math.ceil((world.totalBlockUntil! - t) / DAY) : 0, + until: totalActive ? iso(world.totalBlockUntil!) : null, + }, + manual_pick_locked: world.lock === 'manual' || world.manualPicks.length > 0, + manual_picks: world.manualPicks.map((p) => ({ + app_id: p.app_id, + name: gameName(p.app_id), + age_days: Math.floor((t - p.picked_at) / DAY), + })), + } +} + +export function dataset(): WebDataset { + const games = GAMES.filter(([id]) => !world.finished.includes(id)).map(([id, name]) => + makeWebGame(id, name), + ) + const summary = { qualifying: games.length - 6, rush_total: 812, leisure_total: 1460, worst_total: 1930 } + return { + games, + state: { + current_app_id: world.currentAppId, + current_game_name: world.currentAppId === null ? '' : gameName(world.currentAppId), + games_done: world.finished.length, + games_done_since_start: world.finished.length, + days_elapsed: 143, + enforcement_started_at: '2026-05-20T08:00:00+00:00', + pace_games_per_day: Math.round((world.finished.length / 143) * 1000) / 1000, + }, + defaults: { + min_comp_100_polls: 3, + min_count_comp: 15, + min_confidence_sum: 18, + min_playable_tier: 'gold', + hours_per_day_presets: [1, 2, 3, 4, 6], + }, + default_summary: summary, + pace_vs_hltb: { + calibration_count: world.finished.length, + ratio_vs_rush: 1.12, + ratio_vs_leisure: 0.62, + interpolation_t: 0.18, + player_style: 'rush_to_leisure', + }, + generated_at: new Date().toISOString(), + } +} + +export function stats(): StatsPayload { + const d = dataset() + return { default_summary: d.default_summary, pace_vs_hltb: d.pace_vs_hltb } +} + +export function setupStatus(): SetupStatus { + return { configured: world.configured, has_api_key: world.configured, steam_id: world.steamId } +} + +export function daemonStatus(): DaemonStatus { + const reachable = world.daemon !== 'unreachable' + const t = Date.now() + return { + state: world.daemon, + started_at: reachable ? iso(world.daemonStartedAt) : null, + pid: reachable ? 4123 : null, + journal_tail: reachable ? world.journal.slice(-60) : [], + restart_available_at: + world.restartAvailableAt !== null && world.restartAvailableAt > t ? iso(world.restartAvailableAt) : null, + } +} + +export function serverHealth(): ServerHealth { + return { stale: world.stale, started_at: iso(world.serverStartedAt), version: '0.42.0+mock' } +} + +/** Background chatter so the journal tail visibly moves. */ +setInterval(() => { + if (world.daemon !== 'running') return + const lines = [ + 'tick: Hades engaged, budget ok', + 'hosts guard: /etc/hosts unchanged', + 'library hider: 0 changes', + 'store blocker: rules intact', + ] + journal(lines[Math.floor(Date.now() / 5000) % lines.length]) +}, 5000).unref() + +/** `GET /api/library`: every fixture game, with the real server's reasons. */ +export function library(): LibraryPayload { + const installed = new Set(world.installed.map((g) => g.app_id)) + return { + games: GAMES.map(([id, name]) => { + const h = hash(id) + const done = world.finished.includes(id) + const total = h % 11 === 0 ? 0 : 10 + (h % 60) + const unlocked = done ? total : h % 4 === 0 ? h % total || 0 : 0 + const skipped = !done && total > 0 && h % 9 === 0 + return { + app_id: id, + name, + achievements_total: total, + achievements_unlocked: total ? unlocked : 0, + hltb_hours: h % 13 === 0 ? null : 4 + (h % 60), + playtime_minutes: h % 3 === 0 ? h % 900 : 0, + last_played: h % 3 === 0 ? 1_790_000_000 - (h % 5_000_000) : null, + installed: installed.has(id), + assigned: id === world.currentAppId, + ineligible_reason: total === 0 ? 'No achievements' : done ? 'Already 100% complete' : skipped ? 'Skipped until 2026-10-17' : null, + } + }), + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 188bf97..aac9b04 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,11 +8,15 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@tanstack/react-query": "5.104.1", + "@tanstack/react-router": "1.170.41", "react": "19.3.0", "react-dom": "19.3.0" }, "devDependencies": { + "@babel/core": "8.0.7", "@eslint/js": "10.0.1", + "@rolldown/plugin-babel": "0.2.4", "@testing-library/dom": "10.4.2", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", @@ -22,6 +26,7 @@ "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.2", "@vitest/coverage-v8": "5.0.3", + "babel-plugin-react-compiler": "1.0.0", "eslint": "10.12.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.7", @@ -109,88 +114,225 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.5.tgz", + "integrity": "sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.7.tgz", + "integrity": "sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", + "@babel/code-frame": "^8.0.6", + "@babel/generator": "^8.0.6", + "@babel/helper-compilation-targets": "^8.0.7", + "@babel/helpers": "^8.0.7", + "@babel/parser": "^8.0.7", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.7", + "@babel/types": "^8.0.6", + "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", - "debug": "^4.1.0", + "empathic": "^2.0.1", "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", - "semver": "^6.3.1" + "obug": "^3.0.0", + "verkit": "^0.5.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "node_modules/@babel/core/node_modules/@babel/code-frame": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", + "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/helper-validator-identifier": "^8.0.6", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/core/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/core/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@babel/generator": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.6.tgz", + "integrity": "sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^8.0.6", + "@babel/types": "^8.0.6", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", + "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.7.tgz", + "integrity": "sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^8.0.5", + "@babel/helper-validator-option": "^8.0.0", + "browserslist": "^4.24.0", + "flru": "1.0.2", + "verkit": "^0.5.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.6.tgz", + "integrity": "sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helper-module-imports": { @@ -207,22 +349,76 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "node_modules/@babel/helper-module-imports/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@babel/helper-string-parser": { @@ -246,37 +442,71 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", + "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.7.tgz", + "integrity": "sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.6" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -296,43 +526,199 @@ } }, "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", + "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/code-frame": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/types": "^8.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" } }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", + "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" + "@babel/helper-validator-identifier": "^8.0.6", + "js-tokens": "^10.0.0" }, "engines": { - "node": ">=6.9.0" + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/template/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/traverse": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.7.tgz", + "integrity": "sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.6", + "@babel/generator": "^8.0.6", + "@babel/helper-globals": "^8.0.6", + "@babel/parser": "^8.0.7", + "@babel/template": "^8.0.0", + "@babel/types": "^8.0.6", + "obug": "^3.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.6.tgz", + "integrity": "sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^8.0.6", + "js-tokens": "^10.0.0" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/helper-string-parser": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.6.tgz", + "integrity": "sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/helper-validator-identifier": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.6.tgz", + "integrity": "sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.7.tgz", + "integrity": "sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^8.0.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "8.0.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.6.tgz", + "integrity": "sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^8.0.6", + "@babel/helper-validator-identifier": "^8.0.6" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + } + }, + "node_modules/@babel/traverse/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/traverse/node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -743,13 +1129,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "version": "0.4.0-beta.0", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.4.0-beta.0.tgz", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", "@jridgewell/trace-mapping": "^0.3.24" } }, @@ -764,6 +1150,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1099,6 +1496,37 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/plugin-babel": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@rolldown/plugin-babel/-/plugin-babel-0.2.4.tgz", + "integrity": "sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^4.0.7" + }, + "engines": { + "node": ">=22.12.0 || ^24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.29.0 || ^8.0.0-rc.1", + "@babel/plugin-transform-runtime": "^7.29.0 || ^8.0.0-rc.1", + "@babel/runtime": "^7.27.0 || ^8.0.0-rc.1", + "rolldown": "^1.0.0-rc.5", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@babel/plugin-transform-runtime": { + "optional": true + }, + "@babel/runtime": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", @@ -1106,6 +1534,115 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/history": { + "version": "1.162.4", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.4.tgz", + "integrity": "sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.41", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.41.tgz", + "integrity": "sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.4", + "@tanstack/react-store": "^0.11.2", + "@tanstack/router-core": "1.171.34", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.11.2.tgz", + "integrity": "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.11.2", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.34", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.34.tgz", + "integrity": "sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.4", + "cookie-es": "^3.0.0", + "seroval": "^1.6.7", + "seroval-plugins": "^1.6.7" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.2.tgz", + "integrity": "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.2", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", @@ -1243,6 +1780,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/gensync": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", + "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsesc": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", + "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1745,6 +2296,16 @@ "dev": true, "license": "MIT" }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -1756,9 +2317,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.32", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", - "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "version": "2.11.28", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz", + "integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1792,9 +2353,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ { @@ -1812,11 +2373,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -1840,9 +2401,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "version": "1.0.30001815", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz", + "integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==", "dev": true, "funding": [ { @@ -1877,6 +2438,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1994,12 +2561,22 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "version": "1.5.452", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", + "integrity": "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg==", "dev": true, "license": "ISC" }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2122,6 +2699,178 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/eslint-plugin-react-hooks/node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/eslint-plugin-react-refresh": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", @@ -2333,6 +3082,16 @@ "dev": true, "license": "ISC" }, + "node_modules/flru": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/flru/-/flru-1.0.2.tgz", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2444,6 +3203,17 @@ "node": ">= 4" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -2494,6 +3264,15 @@ "dev": true, "license": "MIT" }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3008,9 +3787,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "version": "2.0.58", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.58.tgz", + "integrity": "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==", "dev": true, "license": "MIT", "engines": { @@ -3333,6 +4112,27 @@ "semver": "bin/semver.js" } }, + "node_modules/seroval": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.9.tgz", + "integrity": "sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.9.tgz", + "integrity": "sha512-kiDK/Tb8IWZ61WEU8pA2PDmAuw37gKph9Y77OZlD56mg0Y2g0Bg7gZ1HeHhs5d40w+nRoUq/wj08V26ilbez9g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3561,9 +4361,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -3601,6 +4401,28 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/verkit": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.5.0.tgz", + "integrity": "sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, "node_modules/vite": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", diff --git a/web/package.json b/web/package.json index f596bfa..f74d150 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:mock": "vite --mode mock", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", @@ -13,11 +14,15 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "@tanstack/react-query": "5.104.1", + "@tanstack/react-router": "1.170.41", "react": "19.3.0", "react-dom": "19.3.0" }, "devDependencies": { + "@babel/core": "8.0.7", "@eslint/js": "10.0.1", + "@rolldown/plugin-babel": "0.2.4", "@testing-library/dom": "10.4.2", "@testing-library/jest-dom": "7.0.1", "@testing-library/react": "16.3.3", @@ -27,6 +32,7 @@ "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "6.1.2", "@vitest/coverage-v8": "5.0.3", + "babel-plugin-react-compiler": "1.0.0", "eslint": "10.12.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.7", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 9eef639..6c69ee2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: 5.104.1 + version: 5.104.1(react@19.3.0) + '@tanstack/react-router': + specifier: 1.170.41 + version: 1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -15,9 +21,15 @@ importers: specifier: 19.3.0 version: 19.3.0(react@19.3.0) devDependencies: + '@babel/core': + specifier: 8.0.7 + version: 8.0.7 '@eslint/js': specifier: 10.0.1 version: 10.0.1(eslint@10.12.0(supports-color@7.2.0)) + '@rolldown/plugin-babel': + specifier: 0.2.4 + version: 0.2.4(@babel/core@8.0.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@26.6.4)) '@testing-library/dom': specifier: 10.4.2 version: 10.4.2 @@ -40,11 +52,14 @@ importers: specifier: 19.3.0 version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': - specifier: 6.1.1 - version: 6.1.1(vite@8.3.2(@types/node@26.6.4)) + specifier: 6.1.2 + version: 6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@26.6.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@26.6.4)) '@vitest/coverage-v8': specifier: 5.0.3 version: 5.0.3(vitest@5.0.3) + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 eslint: specifier: 10.12.0 version: 10.12.0(supports-color@7.2.0) @@ -64,14 +79,14 @@ importers: specifier: 6.0.3 version: 6.0.3 typescript-eslint: - specifier: 8.71.0 - version: 8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + specifier: 8.71.1 + version: 8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) vite: - specifier: 8.3.2 - version: 8.3.2(@types/node@26.6.4) + specifier: 8.3.4 + version: 8.3.4(@types/node@26.6.4) vitest: specifier: 5.0.3 - version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.2(@types/node@26.6.4)) + version: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)) packages: @@ -90,26 +105,50 @@ packages: resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/compat-data@7.29.7': resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} + '@babel/compat-data@8.0.5': + resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/core@7.29.7': resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} + '@babel/core@8.0.7': + resolution: {integrity: sha512-ZIsZWBG6Y1oa+8fkQVp0xeCdjCHBkXFCCIWrmg9SvejVvfcVQgKKRmGrBDWfM7rilhvnRoEuaqa5dkK7KB3pHA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/generator@7.29.8': resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} + '@babel/generator@8.0.6': + resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-compilation-targets@7.29.7': resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} + '@babel/helper-compilation-targets@8.0.7': + resolution: {integrity: sha512-ilAZCdOOlOVA7dQbaFNw2f/59p6GW33NxTopc3DvEE3efp3ZXTrGX/yZScaKw8/vd9bq31Ag0EcXiLw32bAgJg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} + '@babel/helper-globals@8.0.6': + resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} @@ -124,22 +163,43 @@ packages: resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-identifier@7.29.7': resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helper-validator-option@7.29.7': resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/helpers@7.29.7': resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} + '@babel/helpers@8.0.7': + resolution: {integrity: sha512-5dMj5kFwS2AY7Dt4PZp4px3c5ThMbbEa7tZfuHTncCGSFYq8uoA1ufDE1IS/lYpQedE+3+GQ3cPZUiplIu195w==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/parser@7.29.8': resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} + '@babel/parser@8.0.7': + resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -148,14 +208,26 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/traverse@7.29.8': resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} + '@babel/traverse@8.0.7': + resolution: {integrity: sha512-bVV0brinsTWgc6ptSIoQo3GbITtl0VZAqEUJbHHcyfDPEu+q/fEvlYEa+6H85HoK8IdksKBFtKJbJFZ6Kff+zg==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/types@7.29.8': resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -286,6 +358,9 @@ packages: '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.4.0-beta.0': + resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + '@jridgewell/remapping@2.3.5': resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} @@ -308,108 +383,157 @@ packages: '@keyv/serialize@1.1.1': resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} - '@oxc-project/types@0.151.0': - resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} - '@rolldown/binding-android-arm-eabi@1.2.11': - resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@rolldown/binding-android-arm64@1.2.11': - resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.2.11': - resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.2.11': - resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.2.11': - resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.2.11': - resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.2.11': - resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.2.11': - resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.2.11': - resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.2.11': - resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.2.11': - resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.2.11': - resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.2.11': - resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-win32-arm64-msvc@1.2.11': - resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.2.11': - resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/plugin-babel@0.2.4': + resolution: {integrity: sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@tanstack/history@1.162.4': + resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.104.1': + resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==} + + '@tanstack/react-query@5.104.1': + resolution: {integrity: sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.41': + resolution: {integrity: sha512-Mpvw8Wm5MGDbTqI8Yc9flLKxbOHwoWocv0H2aGKcgw2a017oK5nZWqa954P+DrUx/8fI98YNywnHP5XcD3tQ9w==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.11.2': + resolution: {integrity: sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.34': + resolution: {integrity: sha512-X+19pGuuLVPvvAvPYw9v/+ktRk5cCll8AtfpBpOhDRmVgUMrfqze0467xtq8YRVVs/mmo5rVtWn/7Tp32RDBKw==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.11.2': + resolution: {integrity: sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q==} + '@testing-library/dom@10.4.2': resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} engines: {node: '>=18'} @@ -460,6 +584,12 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -474,72 +604,72 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} - '@typescript-eslint/eslint-plugin@8.71.0': - resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + '@typescript-eslint/eslint-plugin@8.71.1': + resolution: {integrity: sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.71.0 + '@typescript-eslint/parser': ^8.71.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/parser@8.71.0': - resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} + '@typescript-eslint/parser@8.71.1': + resolution: {integrity: sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/project-service@8.71.0': - resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + '@typescript-eslint/project-service@8.71.1': + resolution: {integrity: sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/scope-manager@8.71.0': - resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + '@typescript-eslint/scope-manager@8.71.1': + resolution: {integrity: sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.71.0': - resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + '@typescript-eslint/tsconfig-utils@8.71.1': + resolution: {integrity: sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/type-utils@8.71.0': - resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + '@typescript-eslint/type-utils@8.71.1': + resolution: {integrity: sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/types@8.71.0': - resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + '@typescript-eslint/types@8.71.1': + resolution: {integrity: sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.71.0': - resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + '@typescript-eslint/typescript-estree@8.71.1': + resolution: {integrity: sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/utils@8.71.0': - resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + '@typescript-eslint/utils@8.71.1': + resolution: {integrity: sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/visitor-keys@8.71.0': - resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + '@typescript-eslint/visitor-keys@8.71.1': + resolution: {integrity: sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@vitejs/plugin-react@6.1.1': - resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + '@vitejs/plugin-react@6.1.2': + resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==} engines: {node: ^20.19.0 || >=22.12.0} peerDependencies: '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 babel-plugin-react-compiler: ^1.0.0 - oxc-transform-react: ^0.145.0 + oxc-transform-react: ^0.152.0 vite: ^8.0.0 peerDependenciesMeta: '@rolldown/plugin-babel': @@ -615,6 +745,9 @@ packages: ast-v8-to-istanbul@1.0.5: resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -622,6 +755,7 @@ packages: baseline-browser-mapping@2.11.19: resolution: {integrity: sha512-Grytf1xOxOEMTGRwx6rLGKkTabd4vMg3VrKdj/7joCmV0qgh4QwMMO6xh34YEXQqirAuUdgQGa5orJQQ+69RBw==} engines: {node: '>=6.0.0'} + hasBin: true bidi-js@1.1.0: resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} @@ -633,6 +767,7 @@ packages: browserslist@4.28.8: resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true cacheable@2.5.0: resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} @@ -647,6 +782,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -697,6 +835,10 @@ packages: electron-to-chromium@1.5.415: resolution: {integrity: sha512-958V+Kbhtgz+SxXeEVKBjrlKRBIDAYvUJfwhjxMZ5S6ut9jAl7l9ZKBkBrvjyjZE36PabLUo2L8kEeV5O4vgJg==} + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -803,6 +945,10 @@ packages: flatted@3.4.4: resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -852,6 +998,9 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -871,6 +1020,10 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -892,6 +1045,7 @@ packages: jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} + hasBin: true json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -902,6 +1056,7 @@ packages: json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} + hasBin: true keyv@5.6.0: resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} @@ -1018,9 +1173,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.18: - resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1033,6 +1189,10 @@ packages: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1063,8 +1223,8 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} - postcss@8.5.28: - resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -1103,9 +1263,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} - rolldown@1.2.11: - resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true saxes@6.0.0: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} @@ -1120,6 +1281,17 @@ packages: semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} + hasBin: true + + seroval-plugins@1.6.9: + resolution: {integrity: sha512-kiDK/Tb8IWZ61WEU8pA2PDmAuw37gKph9Y77OZlD56mg0Y2g0Bg7gZ1HeHhs5d40w+nRoUq/wj08V26ilbez9g==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.9: + resolution: {integrity: sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==} + engines: {node: '>=10'} shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -1133,6 +1305,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} @@ -1185,8 +1361,8 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} - typescript-eslint@8.71.0: - resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} + typescript-eslint@8.71.1: + resolution: {integrity: sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -1212,8 +1388,17 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - vite@8.3.2: - resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==} + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} + engines: {node: '>=18.12.0'} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -1373,8 +1558,15 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@8.0.6': + dependencies: + '@babel/helper-validator-identifier': 8.0.6 + js-tokens: 10.0.0 + '@babel/compat-data@7.29.7': {} + '@babel/compat-data@8.0.5': {} + '@babel/core@7.29.7(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -1395,6 +1587,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/core@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-compilation-targets': 8.0.7 + '@babel/helpers': 8.0.7 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.7 + '@babel/types': 8.0.6 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.1.0 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 3.0.0 + verkit: 0.5.0 + '@babel/generator@7.29.8': dependencies: '@babel/parser': 7.29.8 @@ -1403,6 +1614,15 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 + '@babel/generator@8.0.6': + dependencies: + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + '@jridgewell/gen-mapping': 0.4.0-beta.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + '@babel/helper-compilation-targets@7.29.7': dependencies: '@babel/compat-data': 7.29.7 @@ -1411,8 +1631,18 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 + '@babel/helper-compilation-targets@8.0.7': + dependencies: + '@babel/compat-data': 8.0.5 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.28.8 + flru: 1.0.2 + verkit: 0.5.0 + '@babel/helper-globals@7.29.7': {} + '@babel/helper-globals@8.0.6': {} + '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': dependencies: '@babel/traverse': 7.29.8(supports-color@7.2.0) @@ -1431,19 +1661,34 @@ snapshots: '@babel/helper-string-parser@7.29.7': {} + '@babel/helper-string-parser@8.0.6': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-identifier@8.0.6': {} + '@babel/helper-validator-option@7.29.7': {} + '@babel/helper-validator-option@8.0.0': {} + '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 '@babel/types': 7.29.8 + '@babel/helpers@8.0.7': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + '@babel/parser@7.29.8': dependencies: '@babel/types': 7.29.8 + '@babel/parser@8.0.7': + dependencies: + '@babel/types': 8.0.6 + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': @@ -1452,6 +1697,12 @@ snapshots: '@babel/parser': 7.29.8 '@babel/types': 7.29.8 + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + '@babel/traverse@7.29.8(supports-color@7.2.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -1464,11 +1715,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@8.0.7': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-globals': 8.0.6 + '@babel/parser': 8.0.7 + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + obug: 3.0.0 + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + '@bcoe/v8-coverage@1.0.2': {} '@bramus/specificity@2.4.2': @@ -1570,6 +1836,11 @@ snapshots: '@jridgewell/sourcemap-codec': 1.6.0 '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/gen-mapping@0.4.0-beta.0': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/remapping@2.3.5': dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -1592,55 +1863,98 @@ snapshots: '@keyv/serialize@1.1.1': {} - '@oxc-project/types@0.151.0': {} + '@oxc-project/types@0.153.0': {} - '@rolldown/binding-android-arm-eabi@1.2.11': + '@rolldown/binding-android-arm-eabi@1.2.13': optional: true - '@rolldown/binding-android-arm64@1.2.11': + '@rolldown/binding-android-arm64@1.2.13': optional: true - '@rolldown/binding-darwin-arm64@1.2.11': + '@rolldown/binding-darwin-arm64@1.2.13': optional: true - '@rolldown/binding-darwin-x64@1.2.11': + '@rolldown/binding-darwin-x64@1.2.13': optional: true - '@rolldown/binding-freebsd-x64@1.2.11': + '@rolldown/binding-freebsd-x64@1.2.13': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': optional: true - '@rolldown/binding-linux-arm64-gnu@1.2.11': + '@rolldown/binding-linux-arm64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-arm64-musl@1.2.11': + '@rolldown/binding-linux-arm64-musl@1.2.13': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.2.11': + '@rolldown/binding-linux-ppc64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-s390x-gnu@1.2.11': + '@rolldown/binding-linux-s390x-gnu@1.2.13': optional: true - '@rolldown/binding-linux-x64-gnu@1.2.11': + '@rolldown/binding-linux-x64-gnu@1.2.13': optional: true - '@rolldown/binding-linux-x64-musl@1.2.11': + '@rolldown/binding-linux-x64-musl@1.2.13': optional: true - '@rolldown/binding-openharmony-arm64@1.2.11': + '@rolldown/binding-openharmony-arm64@1.2.13': optional: true - '@rolldown/binding-win32-arm64-msvc@1.2.11': + '@rolldown/binding-win32-arm64-msvc@1.2.13': optional: true - '@rolldown/binding-win32-x64-msvc@1.2.11': + '@rolldown/binding-win32-x64-msvc@1.2.13': optional: true + '@rolldown/plugin-babel@0.2.4(@babel/core@8.0.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@26.6.4))': + dependencies: + '@babel/core': 8.0.7 + picomatch: 4.0.7 + rolldown: 1.2.13 + optionalDependencies: + '@babel/runtime': 7.29.7 + vite: 8.3.4(@types/node@26.6.4) + '@rolldown/pluginutils@1.0.1': {} + '@tanstack/history@1.162.4': {} + + '@tanstack/query-core@5.104.1': {} + + '@tanstack/react-query@5.104.1(react@19.3.0)': + dependencies: + '@tanstack/query-core': 5.104.1 + react: 19.3.0 + + '@tanstack/react-router@1.170.41(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/history': 1.162.4 + '@tanstack/react-store': 0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-core': 1.171.34 + isbot: 5.2.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@tanstack/react-store@0.11.2(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/store': 0.11.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + + '@tanstack/router-core@1.171.34': + dependencies: + '@tanstack/history': 1.162.4 + cookie-es: 3.1.1 + seroval: 1.6.9 + seroval-plugins: 1.6.9(seroval@1.6.9) + + '@tanstack/store@0.11.2': {} + '@testing-library/dom@10.4.2': dependencies: '@babel/code-frame': 7.29.7 @@ -1662,7 +1976,7 @@ snapshots: picocolors: 1.1.1 redent: 3.0.0 optionalDependencies: - vitest: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.2(@types/node@26.6.4)) + vitest: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)) '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': dependencies: @@ -1691,6 +2005,10 @@ snapshots: '@types/estree@1.0.9': {} + '@types/gensync@1.0.5': {} + + '@types/jsesc@2.5.1': {} + '@types/json-schema@7.0.15': {} '@types/node@26.6.4': @@ -1705,14 +2023,14 @@ snapshots: dependencies: csstype: 3.2.3 - '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/eslint-plugin@8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/type-utils': 8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/type-utils': 8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 eslint: 10.12.0(supports-color@7.2.0) ignore: 7.0.6 natural-compare: 1.4.0 @@ -1721,41 +2039,41 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@7.2.0) eslint: 10.12.0(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.71.0(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/project-service@8.71.1(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) - '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 debug: 4.4.3(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.71.0': + '@typescript-eslint/scope-manager@8.71.1': dependencies: - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 - '@typescript-eslint/tsconfig-utils@8.71.0(typescript@6.0.3)': + '@typescript-eslint/tsconfig-utils@8.71.1(typescript@6.0.3)': dependencies: typescript: 6.0.3 - '@typescript-eslint/type-utils@8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/type-utils@8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) debug: 4.4.3(supports-color@7.2.0) eslint: 10.12.0(supports-color@7.2.0) ts-api-utils: 2.5.0(typescript@6.0.3) @@ -1763,14 +2081,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.71.0': {} + '@typescript-eslint/types@8.71.1': {} - '@typescript-eslint/typescript-estree@8.71.0(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/typescript-estree@8.71.1(supports-color@7.2.0)(typescript@6.0.3)': dependencies: - '@typescript-eslint/project-service': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@6.0.3) - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/visitor-keys': 8.71.0 + '@typescript-eslint/project-service': 8.71.1(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.71.1(typescript@6.0.3) + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/visitor-keys': 8.71.1 debug: 4.4.3(supports-color@7.2.0) minimatch: 10.2.6 semver: 7.8.5 @@ -1780,26 +2098,29 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': + '@typescript-eslint/utils@8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.10.1(eslint@10.12.0(supports-color@7.2.0)) - '@typescript-eslint/scope-manager': 8.71.0 - '@typescript-eslint/types': 8.71.0 - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.71.1 + '@typescript-eslint/types': 8.71.1 + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@7.2.0)(typescript@6.0.3) eslint: 10.12.0(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.71.0': + '@typescript-eslint/visitor-keys@8.71.1': dependencies: - '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/types': 8.71.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@6.1.1(vite@8.3.2(@types/node@26.6.4))': + '@vitejs/plugin-react@6.1.2(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@26.6.4)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.4(@types/node@26.6.4))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.3.2(@types/node@26.6.4) + vite: 8.3.4(@types/node@26.6.4) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.7)(@babel/runtime@7.29.7)(rolldown@1.2.13)(vite@8.3.4(@types/node@26.6.4)) + babel-plugin-react-compiler: 1.0.0 '@vitest/coverage-v8@5.0.3(vitest@5.0.3)': dependencies: @@ -1811,7 +2132,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.2(@types/node@26.6.4)) + vitest: 5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -1819,14 +2140,14 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.1 - '@vitest/mocker@5.0.3(vite@8.3.2(@types/node@26.6.4))': + '@vitest/mocker@5.0.3(vite@8.3.4(@types/node@26.6.4))': dependencies: '@jridgewell/trace-mapping': 0.3.31 '@vitest/spy': 5.0.3 estree-walker: 3.0.3 magic-string: 1.3.1 optionalDependencies: - vite: 8.3.2(@types/node@26.6.4) + vite: 8.3.4(@types/node@26.6.4) '@vitest/spy@5.0.3': {} @@ -1861,6 +2182,10 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.8 + balanced-match@4.0.4: {} baseline-browser-mapping@2.11.19: {} @@ -1895,6 +2220,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1937,6 +2264,8 @@ snapshots: electron-to-chromium@1.5.415: {} + empathic@2.1.0: {} + entities@8.0.0: {} es-module-lexer@2.3.2: {} @@ -2057,6 +2386,8 @@ snapshots: flatted@3.4.4: {} + flru@1.0.2: {} + fsevents@2.3.3: optional: true @@ -2095,6 +2426,8 @@ snapshots: ignore@7.0.6: {} + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -2107,6 +2440,8 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + isbot@5.2.2: {} + isexe@2.0.0: {} js-tokens@10.0.0: {} @@ -2236,7 +2571,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.18: {} + nanoid@3.3.20: {} natural-compare@1.4.0: {} @@ -2244,6 +2579,8 @@ snapshots: obug@2.1.4: {} + obug@3.0.0: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2273,11 +2610,11 @@ snapshots: picomatch@4.0.7: {} - postcss@8.5.28: + postcss@8.5.29: dependencies: - nanoid: 3.3.18 + nanoid: 3.3.20 picocolors: 1.1.1 - source-map-js: 1.2.1 + source-map-js: 1.2.2 prelude-ls@1.2.1: {} @@ -2309,26 +2646,26 @@ snapshots: require-from-string@2.0.2: {} - rolldown@1.2.11: + rolldown@1.2.13: dependencies: - '@oxc-project/types': 0.151.0 + '@oxc-project/types': 0.153.0 '@rolldown/pluginutils': 1.0.1 optionalDependencies: - '@rolldown/binding-android-arm-eabi': 1.2.11 - '@rolldown/binding-android-arm64': 1.2.11 - '@rolldown/binding-darwin-arm64': 1.2.11 - '@rolldown/binding-darwin-x64': 1.2.11 - '@rolldown/binding-freebsd-x64': 1.2.11 - '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 - '@rolldown/binding-linux-arm64-gnu': 1.2.11 - '@rolldown/binding-linux-arm64-musl': 1.2.11 - '@rolldown/binding-linux-ppc64-gnu': 1.2.11 - '@rolldown/binding-linux-s390x-gnu': 1.2.11 - '@rolldown/binding-linux-x64-gnu': 1.2.11 - '@rolldown/binding-linux-x64-musl': 1.2.11 - '@rolldown/binding-openharmony-arm64': 1.2.11 - '@rolldown/binding-win32-arm64-msvc': 1.2.11 - '@rolldown/binding-win32-x64-msvc': 1.2.11 + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 saxes@6.0.0: dependencies: @@ -2340,6 +2677,12 @@ snapshots: semver@7.8.5: {} + seroval-plugins@1.6.9(seroval@1.6.9): + dependencies: + seroval: 1.6.9 + + seroval@1.6.9: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2348,6 +2691,8 @@ snapshots: source-map-js@1.2.1: {} + source-map-js@1.2.2: {} + std-env@4.2.0: {} strip-indent@3.0.0: @@ -2392,12 +2737,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): + typescript-eslint@8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/parser': 8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/typescript-estree': 8.71.0(supports-color@7.2.0)(typescript@6.0.3) - '@typescript-eslint/utils': 8.71.0(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/eslint-plugin': 8.71.1(@typescript-eslint/parser@8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3))(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/parser': 8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.71.1(supports-color@7.2.0)(typescript@6.0.3) + '@typescript-eslint/utils': 8.71.1(eslint@10.12.0(supports-color@7.2.0))(supports-color@7.2.0)(typescript@6.0.3) eslint: 10.12.0(supports-color@7.2.0) typescript: 6.0.3 transitivePeerDependencies: @@ -2419,21 +2764,27 @@ snapshots: dependencies: punycode: 2.3.1 - vite@8.3.2(@types/node@26.6.4): + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + + verkit@0.5.0: {} + + vite@8.3.4(@types/node@26.6.4): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 - postcss: 8.5.28 - rolldown: 1.2.11 + postcss: 8.5.29 + rolldown: 1.2.13 tinyglobby: 0.2.17 optionalDependencies: '@types/node': 26.6.4 fsevents: 2.3.3 - vitest@5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.2(@types/node@26.6.4)): + vitest@5.0.3(@types/node@26.6.4)(@vitest/coverage-v8@5.0.3)(jsdom@30.1.2)(vite@8.3.4(@types/node@26.6.4)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.3(vite@8.3.2(@types/node@26.6.4)) + '@vitest/mocker': 5.0.3(vite@8.3.4(@types/node@26.6.4)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -2444,7 +2795,7 @@ snapshots: tinybench: 6.1.4 tinyexec: 1.3.0 tinyglobby: 0.2.17 - vite: 8.3.2(@types/node@26.6.4) + vite: 8.3.4(@types/node@26.6.4) why-is-node-running: 3.2.1 optionalDependencies: '@types/node': 26.6.4 diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 882804d..539592f 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -1,130 +1,109 @@ -import { render, screen, waitFor, within } from '@testing-library/react' +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 { makeBudget, makeDataset, makeGame, makeState } from './test/factories' +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 = {}) => ({ + '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() + await act(async () => { + await router.load() + }) + return view +} describe('App', () => { - beforeEach(() => { - vi.restoreAllMocks() - }) + beforeEach(installEventSource) afterEach(() => { + router.history.replace('/') vi.unstubAllGlobals() }) - it('renders the planner after data loads', async () => { - const ds = makeDataset([makeGame({ app_id: 1, name: 'Alpha' })]) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: true, json: async () => ds }), - ) - render() - 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('refreshes every cached view when any job ends', () => { + expect(hub.onTerminal).toBe(refreshAfterJob) }) - it('shows an error when the API call fails', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: 'Server Error' }), - ) - render() - await waitFor(() => - expect(screen.getByText(/Could not load data/i)).toBeInTheDocument(), - ) + 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('handles a non-Error rejection', async () => { - vi.stubGlobal('fetch', vi.fn().mockRejectedValue('network down')) - render() - await waitFor(() => - expect(screen.getByText(/network down/i)).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('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, - }), - }, - ) - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ ok: true, json: async () => ds }), - ) - const user = userEvent.setup() - render() - 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') + it('shows the library at /library', async () => { + stubApi(routes()) + await openAt('/library') + expect(await screen.findByText('1 of 1 games')).toBeInTheDocument() }) - it('switches to the budget tab and back', async () => { - const ds = makeDataset([makeGame({ app_id: 1, name: 'Alpha' })]) - vi.stubGlobal( - 'fetch', - vi.fn().mockImplementation((url: string) => - Promise.resolve( - url.startsWith('/api/budget') - ? { ok: true, json: async () => makeBudget() } - : { ok: true, json: async () => ds }, - ), - ), - ) - const user = userEvent.setup() - render() - await waitFor(() => - expect( - screen.getByRole('heading', { name: 'Backlog Completion Planner' }), - ).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() + }) + }) - await user.click(screen.getByRole('tab', { name: 'Gaming budget' })) - await waitFor(() => - expect(screen.getByRole('heading', { name: 'Today' })).toBeInTheDocument(), - ) - expect(screen.queryByRole('table')).toBeNull() + 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() + }) - await user.click(screen.getByRole('tab', { name: 'Backlog planner' })) - await waitFor(() => - expect( - screen.getByRole('heading', { name: 'Backlog Completion Planner' }), - ).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() }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index e19b9ff..a880f1f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,44 +1,19 @@ -import { useState } from 'react' -import { BudgetPanel } from './components/BudgetPanel' -import { PlannerView } from './components/PlannerView' -import type { TabId } from './types' +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' -/** - * `?demo=1` points the budget tab at the short-budget demo run, which is the - * only way to watch the cutoff engage without spending a real day's budget. - */ -function demoRequested(): boolean { - return new URLSearchParams(window.location.search).get('demo') === '1' -} - -const TABS: { id: TabId; label: string }[] = [ - { id: 'planner', label: 'Backlog planner' }, - { id: 'budget', label: 'Gaming budget' }, -] +// 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() { - const [tab, setTab] = useState('planner') - return ( -
- - {/* The budget panel only mounts on its own tab, so the planner never - pays for its polling — and vice versa. */} - {tab === 'budget' ? : } -
+ + + ) } diff --git a/web/src/api.test.ts b/web/src/api.test.ts new file mode 100644 index 0000000..4057381 --- /dev/null +++ b/web/src/api.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fetchBudget, fetchDataset } from './api' +import { makeBudget, makeDataset } from './test/factories' +import { Reply, stubApi } from './test/harness' + +describe('legacy fetchers', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('fetchDataset returns the JSON document', async () => { + const ds = makeDataset() + stubApi({ 'GET /api/dataset': ds }) + await expect(fetchDataset()).resolves.toEqual(ds) + }) + + it('fetchBudget asks for the demo run only when told to', async () => { + const mock = stubApi({ 'GET /api/budget': makeBudget() }) + await fetchBudget(false) + await fetchBudget(true) + expect(mock.mock.calls.map((c) => c[0])).toEqual(['/api/budget', '/api/budget?demo=1']) + }) + + it('throws with the status on an error response', async () => { + stubApi({ 'GET /api/dataset': new Reply(500, undefined, '') }) + await expect(fetchDataset()).rejects.toThrow('API returned 500 Error') + }) +}) diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts new file mode 100644 index 0000000..7640e10 --- /dev/null +++ b/web/src/api/client.test.ts @@ -0,0 +1,137 @@ +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 { makeJob, makePending } from '../test/fixtures' + +function setToken(token: string | null) { + document.head.querySelectorAll(`meta[name="${TOKEN_META}"]`).forEach((m) => m.remove()) + if (token !== null) { + const m = document.createElement('meta') + m.name = TOKEN_META + m.content = token + document.head.append(m) + } +} + +describe('sessionToken', () => { + afterEach(() => setToken(null)) + it('reads the meta tag, empty when absent', () => { + expect(sessionToken()).toBe('') + setToken('abc') + expect(sessionToken()).toBe('abc') + }) +}) + +describe('request', () => { + beforeEach(() => setToken('tok')) + afterEach(() => { + setToken(null) + sessionStorage.clear() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('sends no token on GET and returns the parsed body', async () => { + const mock = stubApi({ 'GET /api/x': { a: 1 } }) + await expect(request('GET', '/api/x')).resolves.toEqual({ a: 1 }) + const headers = mock.mock.calls[0][1]?.headers as Record + expect(headers).toEqual({ Accept: 'application/json' }) + }) + + it('sends the token and a JSON body on writes', async () => { + const mock = stubApi({ 'POST /api/x': undefined }) + await expect(request('POST', '/api/x', { b: 2 })).resolves.toBeUndefined() + const headers = mock.mock.calls[0][1]?.headers as Record + expect(headers['X-SBE-Token']).toBe('tok') + expect(headers['Content-Type']).toBe('application/json') + expect(bodiesOf(mock, 'POST /api/x')).toEqual([{ b: 2 }]) + }) + + it('turns a transport error into a network failure', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('refused'))) + await expect(request('GET', '/api/x')).rejects.toMatchObject({ code: 'network', message: 'refused', status: 0 }) + vi.stubGlobal('fetch', vi.fn().mockRejectedValue('weird')) + await expect(request('GET', '/api/x')).rejects.toMatchObject({ code: 'network', message: 'weird' }) + }) + + it('maps a JSON error body to its code', async () => { + stubApi({ 'GET /api/x': apiError(409, 'busy', 'job running') }) + const err = await request('GET', '/api/x').catch((e: unknown) => e) + expect(err).toBeInstanceOf(ApiFailure) + expect(err).toMatchObject({ code: 'busy', message: 'job running', status: 409, name: 'ApiFailure' }) + }) + + it('defaults a missing error message to empty', async () => { + stubApi({ 'GET /api/x': new Reply(400, { error: 'invalid_params' }) }) + await expect(request('GET', '/api/x')).rejects.toMatchObject({ code: 'invalid_params', message: '' }) + }) + + it('maps a non-JSON error to an http failure', async () => { + stubApi({ 'GET /api/x': new Reply(502, undefined, 'bad gateway') }) + await expect(request('GET', '/api/x')).rejects.toMatchObject({ code: 'http', message: '502 Error', status: 502 }) + }) + + describe('bad_token', () => { + const reload = vi.fn() + beforeEach(() => { + reload.mockClear() + vi.stubGlobal('location', { ...window.location, reload }) + }) + + it('reloads once, then not again inside the guard window', async () => { + stubApi({ 'POST /api/x': apiError(403, 'bad_token') }) + await expect(request('POST', '/api/x')).rejects.toMatchObject({ code: 'bad_token' }) + await expect(request('POST', '/api/x')).rejects.toMatchObject({ code: 'bad_token' }) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('does not reload for a keepalive request', async () => { + stubApi({ 'DELETE /api/x': apiError(403, 'bad_token') }) + await expect(request('DELETE', '/api/x', undefined, true)).rejects.toBeInstanceOf(ApiFailure) + expect(reload).not.toHaveBeenCalled() + }) + + it('never reloads blind when storage is unavailable', async () => { + stubApi({ 'POST /api/x': apiError(403, 'bad_token') }) + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('denied') + }) + await expect(request('POST', '/api/x')).rejects.toBeInstanceOf(ApiFailure) + expect(reload).not.toHaveBeenCalled() + }) + }) +}) + +describe('api', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('hits every endpoint with the right verb and path', async () => { + const routes: Record = {} + 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', + 'GET /api/backups', 'POST /api/backups/a%2Fb/restore', 'GET /api/jobs', 'GET /api/jobs/j1', + 'POST /api/jobs/j1/answer', 'POST /api/jobs/j1/cancel', 'POST /api/pending/p1/heartbeat', + 'POST /api/pending/p1/commit', 'DELETE /api/pending/p1', + ] + for (const k of keys) routes[k] = null + const mock = stubApi(routes) + await Promise.all([ + api.status(), api.stats(), api.installed(), api.library(), api.commands(), api.setup(), + api.saveSetup({ steam_api_key: 'k', steam_id: 'i' }), api.daemon(), api.server(), api.restartServer(), + api.backups(), api.restoreBackup('a/b', 'phrase'), api.jobs(), api.job('j1'), + api.answer('j1', { prompt_id: 'q', value: 'v' }), api.cancelJob('j1'), api.heartbeat('p1'), + api.commit('p1', 'phrase'), api.dropPending('p1'), + ]) + expect(mock).toHaveBeenCalledTimes(keys.length) + expect(bodiesOf(mock, 'POST /api/backups/a%2Fb/restore')).toEqual([{ confirm_phrase: 'phrase' }]) + expect(bodiesOf(mock, 'POST /api/pending/p1/commit')).toEqual([{ confirm_phrase: 'phrase' }]) + }) + + it('startJob tells a job from a pending action', async () => { + stubApi({ 'POST /api/jobs': (r) => (r.body && (r.body as { command: string }).command === 'scan' ? makeJob() : makePending()) }) + await expect(startJob({ command: 'scan', params: {} })).resolves.toMatchObject({ kind: 'job' }) + await expect(startJob({ command: 'gaming-reset', params: {} })).resolves.toMatchObject({ kind: 'pending' }) + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..d16ecb9 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,126 @@ +// Typed HTTP client for the web-control API (DOCS-web-control-api.md). +// Every non-GET carries the per-launch token; failures become ApiFailure +// with the server's error code so screens can explain them precisely. + +import { + ENDPOINTS, + TOKEN_HEADER, + TOKEN_META, + type CommandSpec, + type DaemonStatus, + type InstalledPayload, + type LibraryPayload, + type ServerHealth, + type SetupRequest, + type SetupStatus, + type StateBackup, + type StatsPayload, + type StatusPayload, +} from './contract' +import type { ApiError, ApiErrorCode, Job, JobRequest, PendingAction, PromptAnswer } from './jobContract' + +/** `network` = no response at all; `http` = a non-JSON error response. */ +export type FailureCode = ApiErrorCode | 'network' | 'http' + +export class ApiFailure extends Error { + readonly code: FailureCode + readonly status: number + constructor(code: FailureCode, message: string, status: number) { + super(message) + this.name = 'ApiFailure' + this.code = code + this.status = status + } +} + +/** The token the server injected into index.html; empty when absent. */ +export function sessionToken(): string { + return document.querySelector(`meta[name="${TOKEN_META}"]`)?.content ?? '' +} + +const RELOAD_KEY = 'sbe-token-reload-at' +/** A second bad_token within this window means a reload did not help. */ +const RELOAD_GUARD_MS = 30_000 + +/** + * A restarted server mints a new token, so this page's copy is dead: reload + * once to fetch index.html with the new one. The sessionStorage timestamp + * stops a loop when the reload does not fix it (e.g. dev without a token file). + */ +function reloadForNewToken(): void { + try { + const last = Number(sessionStorage.getItem(RELOAD_KEY) ?? 0) + if (Date.now() - last < RELOAD_GUARD_MS) return + sessionStorage.setItem(RELOAD_KEY, String(Date.now())) + } catch { + return // No storage, no loop guard: never reload blind. + } + window.location.reload() +} + +export async function request(method: string, path: string, body?: unknown, keepalive = false): Promise { + const headers: Record = { Accept: 'application/json' } + if (method !== 'GET') headers[TOKEN_HEADER] = sessionToken() + if (body !== undefined) headers['Content-Type'] = 'application/json' + let resp: Response + try { + resp = await fetch(path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + keepalive, + }) + } catch (e) { + throw new ApiFailure('network', e instanceof Error ? e.message : String(e), 0) + } + const text = await resp.text() + if (!resp.ok) { + let parsed: Partial = {} + try { + parsed = JSON.parse(text) as Partial + } catch { + // Not JSON: a proxy error page or a crashed handler. + } + // keepalive requests come from a page that is already closing. + if (parsed.error === 'bad_token' && !keepalive) reloadForNewToken() + if (parsed.error) throw new ApiFailure(parsed.error, parsed.message ?? '', resp.status) + throw new ApiFailure('http', `${resp.status} ${resp.statusText}`, resp.status) + } + return (text ? JSON.parse(text) : undefined) as T +} + +const get = (path: string) => request('GET', path) + +export type JobStart = { kind: 'job'; job: Job } | { kind: 'pending'; pending: PendingAction } + +export const api = { + status: () => get(ENDPOINTS.status), + stats: () => get(ENDPOINTS.stats), + installed: () => get(ENDPOINTS.installed), + library: () => get(ENDPOINTS.library), + commands: () => get(ENDPOINTS.commands), + setup: () => get(ENDPOINTS.setup), + saveSetup: (req: SetupRequest) => request('POST', ENDPOINTS.setup, req), + daemon: () => get(ENDPOINTS.daemon), + server: () => get(ENDPOINTS.server), + restartServer: () => request('POST', ENDPOINTS.serverRestart), + backups: () => get(ENDPOINTS.backups), + restoreBackup: (id: string, phrase: string) => + request('POST', ENDPOINTS.backupRestore(encodeURIComponent(id)), { confirm_phrase: phrase }), + jobs: () => get(ENDPOINTS.jobs), + job: (id: string) => get(ENDPOINTS.job(id)), + answer: (id: string, answer: PromptAnswer) => request('POST', ENDPOINTS.jobAnswer(id), answer), + cancelJob: (id: string) => request('POST', ENDPOINTS.jobCancel(id)), + heartbeat: (id: string) => request('POST', ENDPOINTS.pendingHeartbeat(id)), + commit: (id: string, phrase: string) => + request('POST', ENDPOINTS.pendingCommit(id), { confirm_phrase: phrase }), + /** `keepalive` lets the DELETE survive the page being closed. */ + dropPending: (id: string, keepalive = false) => + request('DELETE', ENDPOINTS.pending(id), undefined, keepalive), +} + +/** POST /api/jobs: 201 → Job, 202 → PendingAction (countdown commands). */ +export async function startJob(req: JobRequest): Promise { + const out = await request('POST', ENDPOINTS.jobs, req) + return 'ready_at' in out ? { kind: 'pending', pending: out } : { kind: 'job', job: out } +} diff --git a/web/src/api/contract.ts b/web/src/api/contract.ts new file mode 100644 index 0000000..7152843 --- /dev/null +++ b/web/src/api/contract.ts @@ -0,0 +1,210 @@ +// Frozen contract between the Python web server and the React app. +// Jobs, prompts, pending actions and errors live in jobContract.ts. +// Prose spec: DOCS-web-control-api.md at the repo root. Change both together. + +import type { BudgetSnapshot, DefaultSummary, WebDataset } from '../types' + +export type CommandName = + | 'status' + | 'list' + | 'stats' + | 'installed' + | 'gaming-status' + | 'check' + | 'scan' + | 'done' + | 'pick' + | 'pick-manual' + | 'abandon-pick' + | 'install' + | 'uninstall' + | 'hide' + | 'unhide' + | 'buy-dlc' + | 'unblock' + | 'add-exception' + | 'block-gaming' + | 'reset' + | 'setup' + | 'enforce' + | 'gaming-reset' + | 'gaming-unblock' + | 'serve' + /** Job spawned by `POST /api/backups/{id}/restore`, not a CLI command. */ + | 'restore-backup' + +/** How the UI surfaces a command. */ +export type CommandKind = + /** Read-only data, rendered from a GET endpoint (`view_endpoint`). */ + | 'view' + /** Runs as a job with live progress. */ + | 'job' + /** Has a dedicated screen (setup wizard, daemon, server health). */ + | 'screen' + +export type CommandCategory = 'backlog' | 'library' | 'gaming' | 'store' | 'system' + +export interface ParamSpec { + name: string + label: string + type: 'int' | 'string' | 'app_id' + required: boolean + min?: number + max?: number + default?: number | string + help?: string +} + +export interface FrictionSpec { + /** + * Template with `{param}` / `{game_name}` / `{count}` placeholders, filled + * in by the server in `PendingAction.phrase` or a `phrase` prompt. The + * server re-checks the typed text; the UI only helps. + */ + phrase_template: string + /** Seconds of armed countdown with heartbeats before commit is allowed. */ + countdown_seconds?: number +} + +export interface CommandSpec { + name: CommandName + description: string + category: CommandCategory + kind: CommandKind + params: ParamSpec[] + friction: FrictionSpec | null + /** Executed by the root daemon over the control socket. */ + privileged: boolean + /** Mutates state; at most one mutating job runs at a time. */ + mutating: boolean + cancellable: boolean + /** Present when the manual-pick or total-block lock forbids it right now. */ + locked_reason: string | null + view_endpoint?: string +} + +// ---- views --------------------------------------------------------------- + +export interface ManualPickInfo { + app_id: number + name: string + age_days: number +} + +export interface StatusPayload { + current_app_id: number | null + current_game_name: string | null + finished_count: number + store_blocked: boolean + installed_count: number + assigned_game_installed: boolean + total_block: { active: boolean; days_remaining: number; until: string | null } + manual_pick_locked: boolean + manual_picks: ManualPickInfo[] +} + +export interface StatsPayload { + default_summary: DefaultSummary + pace_vs_hltb: WebDataset['pace_vs_hltb'] +} + +export interface InstalledGame { + app_id: number + name: string + size_bytes: number + assigned: boolean + protected: boolean +} + +export interface InstalledPayload { + games: InstalledGame[] +} + +/** One owned game in the library browser (`GET /api/library`). */ +export interface LibraryGame { + app_id: number + name: string + /** 0 when the scan has no achievement data for it. */ + achievements_total: number + achievements_unlocked: number + /** Completionist hours (HLTB), null when unknown. */ + hltb_hours: number | null + playtime_minutes: number + /** Unix seconds of the last session, null when never played. */ + last_played: number | null + installed: boolean + /** The assignment or an active manual pick. */ + assigned: boolean + /** Why "pick my own game" refuses it; null when it may be picked. */ + ineligible_reason: string | null +} + +export interface LibraryPayload { + games: LibraryGame[] +} + +export interface SetupStatus { + configured: boolean + has_api_key: boolean + steam_id: string | null +} + +export interface SetupRequest { + steam_api_key: string + steam_id: string +} + +export type DaemonState = 'running' | 'restarting' | 'unreachable' + +export interface DaemonStatus { + state: DaemonState + started_at: string | null + pid: number | null + journal_tail: string[] + /** ISO time before which `restart` is rate-limited, or null. */ + restart_available_at: string | null +} + +export interface ServerHealth { + stale: boolean + started_at: string + version: string +} + +export interface StateBackup { + id: string + created_at: string + reason: string + size_bytes: number +} + +export const ENDPOINTS = { + dataset: '/api/dataset', + budget: '/api/budget', + status: '/api/status', + stats: '/api/stats', + installed: '/api/installed', + library: '/api/library', + /** Portrait cover JPEG; 404 when there is none (draw a title tile). */ + art: (appId: number) => `/api/art/${appId}`, + commands: '/api/commands', + setup: '/api/setup', + daemon: '/api/daemon', + server: '/api/server', + serverRestart: '/api/server/restart', + backups: '/api/backups', + backupRestore: (id: string) => `/api/backups/${id}/restore`, + jobs: '/api/jobs', + job: (id: string) => `/api/jobs/${id}`, + jobEvents: (id: string) => `/api/jobs/${id}/events`, + jobAnswer: (id: string) => `/api/jobs/${id}/answer`, + jobCancel: (id: string) => `/api/jobs/${id}/cancel`, + pendingHeartbeat: (id: string) => `/api/pending/${id}/heartbeat`, + pendingCommit: (id: string) => `/api/pending/${id}/commit`, + pending: (id: string) => `/api/pending/${id}`, +} as const + +export const TOKEN_META = 'sbe-token' +export const TOKEN_HEADER = 'X-SBE-Token' + +export type { BudgetSnapshot, WebDataset } diff --git a/web/src/api/errors.test.ts b/web/src/api/errors.test.ts new file mode 100644 index 0000000..0f73a19 --- /dev/null +++ b/web/src/api/errors.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { ApiFailure } from './client' +import { explain } from './errors' + +describe('explain', () => { + it('uses the table entry and the server message for an ApiFailure', () => { + expect(explain(new ApiFailure('busy', 'job 3 is running', 409))).toMatchObject({ + code: 'busy', + title: 'Another job is running', + detail: 'job 3 is running', + }) + }) + + it('treats any other Error as an unexpected server error', () => { + expect(explain(new Error('boom'))).toMatchObject({ code: 'http', title: 'Unexpected server error', detail: 'boom' }) + }) + + it('stringifies a non-Error', () => { + expect(explain('plain').detail).toBe('plain') + }) +}) diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..431bc6b --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,41 @@ +// Human explanations for every ApiErrorCode. The server's own message is +// shown underneath; this adds what the user should do about it. + +import { ApiFailure, type FailureCode } from './client' + +const EXPLAIN: Record = { + bad_token: { title: 'Session expired', hint: 'The server restarted with a new token. The page reloads itself once; if this stays, reload it.' }, + bad_origin: { title: 'Blocked by origin check', hint: 'Open the UI from the address the server prints, not a copy.' }, + bad_host: { title: 'Blocked by host check', hint: 'Use http://127.0.0.1:8000 or http://localhost:8000.' }, + unknown_command: { title: 'Unknown command', hint: 'The UI and server versions differ. Restart the server from System.' }, + invalid_params: { title: 'Invalid input', hint: 'Fix the highlighted values and try again.' }, + not_found: { title: 'Not found', hint: 'It no longer exists (pruned from history or deleted). Refresh the list.' }, + wrong_phrase: { title: 'Phrase did not match', hint: 'Type the phrase exactly, including case and spacing.' }, + locked: { title: 'Locked', hint: 'A manual-pick or total-block lock forbids this right now.' }, + busy: { title: 'Another job is running', hint: 'Only one state-changing job runs at a time. Wait for it to finish.' }, + countdown_running: { title: 'Countdown still running', hint: 'Wait for the countdown to finish before committing.' }, + pending_lapsed: { title: 'Countdown lapsed', hint: 'A heartbeat was missed (tab asleep or offline). Arm it again.' }, + daemon_unreachable: { title: 'Root daemon unreachable', hint: 'Privileged actions need the enforcer daemon. Check System.' }, + rate_limited: { title: 'Rate limited', hint: 'This action was used recently. Try again later.' }, + not_cancellable: { title: 'Cannot cancel', hint: 'This command cannot be stopped once started.' }, + op_failed: { title: 'Action failed', hint: 'The daemon or the job process hit an error. See the job log or the daemon journal on System.' }, + server_stale: { title: 'Server is restarting', hint: 'It was running outdated code and restarts on the current code. Retry in a few seconds.' }, + unsupported: { title: 'Not supported here', hint: 'The enforcer daemon cannot do this in its current mode.' }, + network: { title: 'Server not reachable', hint: 'Is it running? Start it with ./run.sh serve.' }, + http: { title: 'Unexpected server error', hint: 'Check the server log.' }, +} + +export interface Explained { + code: FailureCode + title: string + hint: string + detail: string +} + +export function explain(err: unknown): Explained { + if (err instanceof ApiFailure) { + return { code: err.code, ...EXPLAIN[err.code], detail: err.message } + } + const detail = err instanceof Error ? err.message : String(err) + return { code: 'http', ...EXPLAIN.http, detail } +} diff --git a/web/src/api/jobContract.ts b/web/src/api/jobContract.ts new file mode 100644 index 0000000..b10ff18 --- /dev/null +++ b/web/src/api/jobContract.ts @@ -0,0 +1,120 @@ +// Job, prompt, pending-action and error shapes of the web control API. +// Split from contract.ts (file-length cap); same frozen contract, same +// prose spec: DOCS-web-control-api.md. Change both together. + +import type { CommandName } from './contract' + +export type JobState = + | 'queued' + | 'running' + | 'waiting_input' + | 'succeeded' + | 'failed' + | 'cancelled' + +export interface JobProgress { + step: number + total: number | null + label: string + item?: string + eta_seconds?: number +} + +export interface Job { + id: string + command: CommandName + params: Record + state: JobState + created_at: string + started_at: string | null + ended_at: string | null + exit_code: number | null + progress: JobProgress | null + summary: string | null +} + +export interface JobRequest { + command: CommandName + params: Record + /** + * Required up front for every friction command. Countdown commands + * (gaming-reset) need it here to arm AND again at commit. + */ + confirm_phrase?: string +} + +/** `game`: pick from the library browser; answer an app id, or '' to go back. */ +export type PromptKind = 'choice' | 'confirm' | 'phrase' | 'text' | 'game' + +export interface PromptOption { + value: string + label: string + detail?: string +} + +interface JobEventBase { + seq: number + ts: string +} + +export type JobEvent = + | (JobEventBase & { type: 'state'; state: JobState }) + | (JobEventBase & { type: 'log'; level: 'info' | 'warning' | 'error'; message: string }) + | (JobEventBase & { type: 'progress' } & JobProgress) + | (JobEventBase & { + type: 'prompt' + prompt_id: string + kind: PromptKind + message: string + options?: PromptOption[] + phrase?: string + default?: string + /** `game` prompts: the only app ids the job will accept. */ + app_ids?: number[] + }) + | (JobEventBase & { type: 'result'; ok: boolean; summary: string; data?: unknown }) + +export interface PromptAnswer { + prompt_id: string + value: string +} + +export interface PendingAction { + id: string + command: CommandName + phrase: string + armed_at: string + ready_at: string + /** Send a heartbeat at least this often or the action lapses. */ + heartbeat_interval_seconds: number +} + +export type ApiErrorCode = + | 'bad_token' + | 'bad_origin' + | 'bad_host' + | 'unknown_command' + | 'invalid_params' + /** Unknown job, backup or endpoint (404). */ + | 'not_found' + | 'wrong_phrase' + | 'locked' + | 'busy' + | 'countdown_running' + | 'pending_lapsed' + | 'daemon_unreachable' + | 'rate_limited' + | 'not_cancellable' + /** Daemon op raised, or a job process could not be started (502). */ + | 'op_failed' + /** The server runs outdated code and is restarting itself (503). */ + | 'server_stale' + /** Daemon cannot do this here, e.g. restart while unsupervised. */ + | 'unsupported' + +export interface ApiError { + error: ApiErrorCode + message: string +} + +/** Every endpoint, so client and mock server share one list. */ diff --git a/web/src/api/queries.test.ts b/web/src/api/queries.test.ts new file mode 100644 index 0000000..32fde99 --- /dev/null +++ b/web/src/api/queries.test.ts @@ -0,0 +1,59 @@ +import { QueryObserver } from '@tanstack/react-query' +import { afterEach, 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 +} + +describe('queries', () => { + afterEach(() => vi.unstubAllGlobals()) + + 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) + }) + + 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 () => { + 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(), + }) + 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 }) + expect(mock).toHaveBeenCalledTimes(all.length) + }) + + 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') + refreshAfterJob() + expect(spy).toHaveBeenCalled() + unsub() + }) +}) diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts new file mode 100644 index 0000000..964fee4 --- /dev/null +++ b/web/src/api/queries.ts @@ -0,0 +1,51 @@ +// 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. + +import { queryOptions, QueryClient } from '@tanstack/react-query' +import { fetchBudget, fetchDataset } from '../api' +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 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, + }), +} + +/** After a job ends, anything it may have changed is stale. */ +export function refreshAfterJob(): void { + void queryClient.invalidateQueries() +} diff --git a/web/src/commands/CommandCard.test.tsx b/web/src/commands/CommandCard.test.tsx new file mode 100644 index 0000000..d8b0eba --- /dev/null +++ b/web/src/commands/CommandCard.test.tsx @@ -0,0 +1,94 @@ +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) => ( + {} }}>{ui} +) + +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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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()) + 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() + }) +}) diff --git a/web/src/commands/CommandCard.tsx b/web/src/commands/CommandCard.tsx new file mode 100644 index 0000000..c75bc11 --- /dev/null +++ b/web/src/commands/CommandCard.tsx @@ -0,0 +1,65 @@ +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 + /** 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 ( +
+

+ {commandLabel(name)} + {spec?.privileged && root} + {spec?.friction?.countdown_seconds ? countdown : null} +

+

{locked ?? spec?.description ?? '…'}

+ +
+ ) +} + +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 ( + + {text} + + ) + } + return ( + + ) +} diff --git a/web/src/commands/CommandPalette.test.tsx b/web/src/commands/CommandPalette.test.tsx new file mode 100644 index 0000000..183f08b --- /dev/null +++ b/web/src/commands/CommandPalette.test.tsx @@ -0,0 +1,134 @@ +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() + 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() + }) +}) diff --git a/web/src/commands/CommandPalette.tsx b/web/src/commands/CommandPalette.tsx new file mode 100644 index 0000000..ca76555 --- /dev/null +++ b/web/src/commands/CommandPalette.tsx @@ -0,0 +1,115 @@ +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) => { + const last = shown.length - 1 + const moves: Record = { + 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 ( + + { + setText(e.target.value) + setActive(0) + }} + onKeyDown={onKey} + /> + {error && } + {!commands && !error && } +
    + {shown.map((e, i) => ( +
  • setActive(i)} + onClick={e.act} + > + + {e.title} + {e.locked && locked} + + {e.locked ?? e.detail} + {e.hint} +
  • + ))} + {shown.length === 0 &&
  • No command matches “{text}”.
  • } +
+

↑↓ to move · Enter to run · Esc to close

+
+ ) +} diff --git a/web/src/commands/ParamsForm.test.tsx b/web/src/commands/ParamsForm.test.tsx new file mode 100644 index 0000000..d7c94b3 --- /dev/null +++ b/web/src/commands/ParamsForm.test.tsx @@ -0,0 +1,59 @@ +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({ app_id: '', days: '', note: '' }) + return +} + +describe('ParamsForm', () => { + it('renders one labelled field per spec, marking the required ones', () => { + render() + 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() + 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() + expect(screen.queryByText('Game is required.')).toBeNull() + rerender() + 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( {}} games={GAMES} showErrors={false} disabled={false} />) + expect(screen.getByLabelText('Note')).toHaveValue('') + }) + + it('disables every field', () => { + render() + expect(screen.getByLabelText('Note')).toBeDisabled() + }) +}) diff --git a/web/src/commands/ParamsForm.tsx b/web/src/commands/ParamsForm.tsx new file mode 100644 index 0000000..e488b4c --- /dev/null +++ b/web/src/commands/ParamsForm.tsx @@ -0,0 +1,62 @@ +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] ?? '' + const err = paramError(p, raw, games) + const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined + return ( +
+ + onChange({ ...values, [p.name]: e.target.value })} + /> + {p.type === 'app_id' && ( + + {games.map((g) => ( + + ))} + + )} +

+ {showErrors && err ? err : game ? game.name : (p.help ?? (p.type === 'app_id' ? 'Type an app id or pick a game from the list.' : ' '))} +

+
+ ) + })} + + ) +} diff --git a/web/src/commands/PendingCountdown.test.tsx b/web/src/commands/PendingCountdown.test.tsx new file mode 100644 index 0000000..8b0d28a --- /dev/null +++ b/web/src/commands/PendingCountdown.test.tsx @@ -0,0 +1,182 @@ +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( + + + , + ) + 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() + expect(screen.getByText('0:30')).toBeInTheDocument() + expect(screen.getByText('until commit unlocks')).toBeInTheDocument() + await tick(10_000) + expect(screen.getByText('0:20')).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'Countdown' })).toHaveAttribute('aria-valuenow', '33') + expect(screen.getByText(/Closing this dialog/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel countdown' })).toHaveFocus() + }) + + 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:55')).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 = { '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:30')).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() + }) + }) +}) diff --git a/web/src/commands/PendingCountdown.tsx b/web/src/commands/PendingCountdown.tsx new file mode 100644 index 0000000..1a0d3b7 --- /dev/null +++ b/web/src/commands/PendingCountdown.tsx @@ -0,0 +1,120 @@ +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' + +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(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) + 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 ( +
+ +
+ +
+
+ ) + } + + return ( +
+
+ {clock(left)} + {isReady ? 'Ready to commit' : 'until commit unlocks'} +
+ +

+ Keep this window open. Heartbeat every {Math.round(pending.heartbeat_interval_seconds / 2)} s · last {sinceBeat} s ago + {beatError !== null && · heartbeat failing, retrying} +

+

+ {isReady ? 'Countdown finished. Retype the phrase to commit.' : ''} +

+ {isReady ? ( +
{ + e.preventDefault() + if (phraseMatches(pending.phrase, phrase)) commit.mutate() + }} + > + + {commit.error && } +
+ + +
+ + ) : ( + <> + Closing this dialog, navigating away or closing the tab cancels the countdown. +
+ +
+ + )} +
+ ) +} diff --git a/web/src/commands/PhraseField.test.tsx b/web/src/commands/PhraseField.test.tsx new file mode 100644 index 0000000..2f27fd4 --- /dev/null +++ b/web/src/commands/PhraseField.test.tsx @@ -0,0 +1,54 @@ +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 +} + +describe('PhraseField', () => { + it('counts characters while the prefix is right', async () => { + render() + 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() + 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() + 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() + 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() + 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() + expect(screen.getByRole('textbox')).toHaveFocus() + expect(screen.getByText('Phrase: reset it')).toBeInTheDocument() + }) +}) diff --git a/web/src/commands/PhraseField.tsx b/web/src/commands/PhraseField.tsx new file mode 100644 index 0000000..2c7cbb2 --- /dev/null +++ b/web/src/commands/PhraseField.tsx @@ -0,0 +1,58 @@ +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 ( +
+ + + e.preventDefault()} + onDrop={(e) => e.preventDefault()} + onChange={(e) => onChange(e.target.value)} + /> +

Phrase: {phrase}

+

+ {status} +

+
+ ) +} diff --git a/web/src/commands/RunnerDialog.countdown.test.tsx b/web/src/commands/RunnerDialog.countdown.test.tsx new file mode 100644 index 0000000..a750b4f --- /dev/null +++ b/web/src/commands/RunnerDialog.countdown.test.tsx @@ -0,0 +1,86 @@ +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) { + const mock = stubApi(routes([SPEC], extra)) + const onClose = vi.fn() + const view = renderRouted() + 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() + }) +}) diff --git a/web/src/commands/RunnerDialog.test.tsx b/web/src/commands/RunnerDialog.test.tsx new file mode 100644 index 0000000..8ea83c3 --- /dev/null +++ b/web/src/commands/RunnerDialog.test.tsx @@ -0,0 +1,214 @@ +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[0]['name'], preset?: Record) { + const onClose = vi.fn() + const view = renderRouted() + 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('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() + }) + }) +}) diff --git a/web/src/commands/RunnerDialog.tsx b/web/src/commands/RunnerDialog.tsx new file mode 100644 index 0000000..b3a820f --- /dev/null +++ b/web/src/commands/RunnerDialog.tsx @@ -0,0 +1,181 @@ +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 { 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> = { + 'gaming-unblock': 'sudo ./run.sh gaming-unblock', +} + +interface Props { + name: CommandName + preset?: Record + 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(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 ( + + {spec ? ( + (armed.current = id)} /> + ) : loadError ? ( + + ) : commands ? ( + The server does not offer "{name}". + ) : ( + + )} + + ) +} + +interface RunnerProps { + spec: CommandSpec + preset?: Record + onClose: () => void + onArmed: (pendingId: string | null) => void +} + +function Runner({ spec, 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({ 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 = 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 ( +
+ +
+ + + Open in Jobs + +
+
+ ) + } + + if (phase.k === 'pending') { + return ( + { + 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 ( +
{ + e.preventDefault() + submit() + }} + > +

{spec.description}

+
+ {spec.privileged && Runs as root via the daemon} + {spec.mutating ? Changes state : Read-only} + {spec.cancellable && Cancellable} + {countdown ? {Math.round(countdown / 60)} min countdown : null} +
+ {locked && {spec.locked_reason}} + + {phrase && phrase.missing.length > 0 && ( + Fill in the fields above to see the exact phrase ({phrase.missing.join(', ')}). + )} + {phrase && phrase.missing.length === 0 && !locked && ( + + )} + {start.error && ( + + {failure?.code === 'daemon_unreachable' && FALLBACK[spec.name] && ( + <> +

Run it from a terminal instead:

+ + + )} +
+ )} +
+ + +
+ + ) +} diff --git a/web/src/commands/RunnerProvider.test.tsx b/web/src/commands/RunnerProvider.test.tsx new file mode 100644 index 0000000..979f96b --- /dev/null +++ b/web/src/commands/RunnerProvider.test.tsx @@ -0,0 +1,95 @@ +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 ( + <> + + + + + ) +} + +function mount() { + stubApi(routes([makeSpec('scan'), makeSpec('done')])) + return renderRouted( + + + , + ) +} + +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() + }) +}) diff --git a/web/src/commands/RunnerProvider.tsx b/web/src/commands/RunnerProvider.tsx new file mode 100644 index 0000000..564f0b0 --- /dev/null +++ b/web/src/commands/RunnerProvider.tsx @@ -0,0 +1,46 @@ +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 + /** 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(null) + const [palette, setPalette] = useState(false) + + const run = useCallback((name: CommandName, preset?: Record) => { + 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 ( + + {children} + {palette && setPalette(false)} onRun={run} />} + {open && } + + ) +} diff --git a/web/src/commands/catalog.test.ts b/web/src/commands/catalog.test.ts new file mode 100644 index 0000000..3a03d6a --- /dev/null +++ b/web/src/commands/catalog.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { makeInstalled, makeSpec } from '../test/fixtures' +import { commandLabel, fillPhrase, findCommand, phraseMatches } from './catalog' + +const ctx = (installed = [makeInstalled()]) => ({ + gameName: (id: number) => (id === 7 ? 'Seven' : null), + installed, +}) + +describe('catalog', () => { + it('labels a command in title case', () => { + expect(commandLabel('pick-manual')).toBe('Pick manual') + expect(commandLabel('scan')).toBe('Scan') + }) + + it('forgives only surrounding whitespace', () => { + expect(phraseMatches('delete it', ' delete it ')).toBe(true) + expect(phraseMatches('delete it', 'Delete it')).toBe(false) + expect(phraseMatches('delete it', 'delete it')).toBe(false) + }) + + it('finds a command in the catalog', () => { + const list = [makeSpec('scan'), makeSpec('done')] + expect(findCommand(list, 'done')?.name).toBe('done') + expect(findCommand(list, 'check')).toBeUndefined() + expect(findCommand(undefined, 'check')).toBeUndefined() + }) +}) + +describe('fillPhrase', () => { + it('fills from params', () => { + expect(fillPhrase('abandon {app_id}', { app_id: 7 }, ctx())).toEqual({ text: 'abandon 7', missing: [] }) + }) + + it('resolves {game_name} from the app id', () => { + expect(fillPhrase('drop {game_name}', { app_id: 7 }, ctx()).text).toBe('drop Seven') + }) + + it('reports an unknown game or a missing app id', () => { + expect(fillPhrase('drop {game_name}', { app_id: 8 }, ctx())).toEqual({ text: 'drop {game_name}', missing: ['game_name'] }) + expect(fillPhrase('drop {game_name}', {}, ctx()).missing).toEqual(['game_name']) + }) + + it('counts removable installed games for {count}', () => { + const installed = [ + makeInstalled({ app_id: 1 }), + makeInstalled({ app_id: 2, assigned: true }), + makeInstalled({ app_id: 3, protected: true }), + makeInstalled({ app_id: 4 }), + ] + expect(fillPhrase('remove {count} games', {}, ctx(installed)).text).toBe('remove 2 games') + }) + + it('cannot count before the installed list is known', () => { + const c = { gameName: () => null, installed: null } + expect(fillPhrase('remove {count}', {}, c).missing).toEqual(['count']) + }) + + it('treats an empty param as missing and an unknown key as missing', () => { + expect(fillPhrase('{app_id} {nope}', { app_id: '' }, ctx()).missing).toEqual(['app_id', 'nope']) + }) +}) diff --git a/web/src/commands/catalog.ts b/web/src/commands/catalog.ts new file mode 100644 index 0000000..bafe34e --- /dev/null +++ b/web/src/commands/catalog.ts @@ -0,0 +1,95 @@ +// Where each command lives in the UI, and client-side phrase previews. +// `satisfies Record` makes the compiler prove all 25 CLI +// commands have a home — adding one to the contract without placing it fails +// the build. + +import type { CommandName, CommandSpec, InstalledGame } from '../api/contract' + +export type SectionPath = + | '/' + | '/backlog' + | '/gaming' + | '/library' + | '/picks' + | '/store' + | '/system' + | '/setup' + +export const COMMAND_HOME = { + status: '/', + list: '/backlog', + stats: '/backlog', + installed: '/library', + 'gaming-status': '/gaming', + check: '/picks', + scan: '/picks', + done: '/picks', + pick: '/picks', + 'pick-manual': '/picks', + 'abandon-pick': '/picks', + install: '/library', + uninstall: '/library', + hide: '/library', + unhide: '/library', + 'buy-dlc': '/store', + unblock: '/store', + 'add-exception': '/store', + 'block-gaming': '/gaming', + reset: '/system', + setup: '/setup', + enforce: '/system', + 'gaming-reset': '/gaming', + 'gaming-unblock': '/gaming', + serve: '/system', + 'restore-backup': '/system', +} as const satisfies Record + +/** Title-case label for buttons and the palette (`pick-manual` → `Pick manual`). */ +export function commandLabel(name: CommandName): string { + const s = name.replace(/-/g, ' ') + return s.charAt(0).toUpperCase() + s.slice(1) +} + +/** Mirror of `_friction.phrase_matches`: only surrounding whitespace is forgiven. */ +export function phraseMatches(expected: string, typed: string): boolean { + return typed.trim() === expected +} + +export interface PhraseContext { + gameName: (appId: number) => string | null + installed: InstalledGame[] | null +} + +export interface FilledPhrase { + text: string + /** Placeholders the UI could not resolve; the phrase cannot be typed yet. */ + missing: string[] +} + +/** + * Fill a FrictionSpec template from the form's params plus what the UI + * knows. The server fills and checks its own copy — this is only so the user + * sees the exact sentence before submitting. + */ +export function fillPhrase( + template: string, + params: Record, + ctx: PhraseContext, +): FilledPhrase { + const missing: string[] = [] + 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 === 'count' && ctx.installed) { + value = String(ctx.installed.filter((g) => !g.assigned && !g.protected).length) + } + if (value === null) missing.push(key) + return value ?? whole + }) + return { text, missing } +} + +export function findCommand(list: CommandSpec[] | undefined, name: CommandName): CommandSpec | undefined { + return list?.find((c) => c.name === name) +} diff --git a/web/src/commands/params.test.ts b/web/src/commands/params.test.ts new file mode 100644 index 0000000..6cb9ed7 --- /dev/null +++ b/web/src/commands/params.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import type { ParamSpec } from '../api/contract' +import { initialValues, paramError, toParams } from './params' + +const spec = (over: Partial): ParamSpec => ({ + name: 'n', label: 'Count', type: 'int', required: true, ...over, +}) +const games = [{ app_id: 5, name: 'Five' }] + +describe('paramError', () => { + it('requires a value unless there is a default or it is optional', () => { + expect(paramError(spec({}), ' ', [])).toBe('Count is required.') + expect(paramError(spec({ default: 3 }), '', [])).toBeNull() + expect(paramError(spec({ required: false }), '', [])).toBeNull() + }) + + it('accepts any text for a string', () => { + expect(paramError(spec({ type: 'string' }), 'anything', [])).toBeNull() + }) + + it('wants whole numbers', () => { + expect(paramError(spec({}), '1.5', [])).toBe('Count must be a whole number.') + expect(paramError(spec({}), '-3', [])).toBeNull() + }) + + it('checks int bounds', () => { + const s = spec({ min: 1, max: 5 }) + expect(paramError(s, '0', [])).toBe('At least 1.') + expect(paramError(s, '6', [])).toBe('At most 5.') + expect(paramError(s, '3', [])).toBeNull() + expect(paramError(spec({}), '99', [])).toBeNull() + }) + + it('checks an app id against the known library', () => { + const s = spec({ type: 'app_id' }) + expect(paramError(s, '0', games)).toBe('Enter a Steam app id.') + expect(paramError(s, '9', games)).toBe('App 9 is not in your library snapshot.') + expect(paramError(s, '5', games)).toBeNull() + expect(paramError(s, '9', [])).toBeNull() + }) +}) + +describe('toParams', () => { + const specs = [ + spec({ name: 'a', type: 'int', default: 7 }), + spec({ name: 'b', type: 'string' }), + spec({ name: 'c', type: 'app_id', required: false }), + spec({ name: 'd', type: 'int' }), + ] + + it('converts, applies defaults and omits empties', () => { + expect(toParams(specs, { a: '', b: ' hi ', c: '42' })).toEqual({ a: 7, b: 'hi', c: 42 }) + }) + + it('keeps a typed int', () => { + expect(toParams(specs, { a: '2', d: '9' })).toEqual({ a: 2, d: 9 }) + }) +}) + +describe('initialValues', () => { + const specs = [spec({ name: 'a', default: 4 }), spec({ name: 'b', type: 'string' }), spec({ name: 'c' })] + + it('prefers the preset, then the default, then empty', () => { + expect(initialValues(specs, { a: 1 })).toEqual({ a: '1', b: '', c: '' }) + expect(initialValues(specs)).toEqual({ a: '4', b: '', c: '' }) + }) +}) diff --git a/web/src/commands/params.ts b/web/src/commands/params.ts new file mode 100644 index 0000000..106a63a --- /dev/null +++ b/web/src/commands/params.ts @@ -0,0 +1,45 @@ +// Pure helpers behind ParamsForm: per-ParamSpec validation and conversion. + +import type { ParamSpec } from '../api/contract' + +export type ParamValues = Record + +export interface GameOption { + app_id: number + name: string +} + +/** Error text for one field, or null when the value is acceptable. */ +export function paramError(p: ParamSpec, raw: string, games: GameOption[]): string | null { + const v = raw.trim() + if (v === '') return p.required && p.default === undefined ? `${p.label} is required.` : null + if (p.type === 'string') return null + if (!/^-?\d+$/.test(v)) return `${p.label} must be a whole number.` + const n = Number(v) + if (p.type === 'app_id') { + if (n <= 0) return 'Enter a Steam app id.' + if (games.length > 0 && !games.some((g) => g.app_id === n)) return `App ${n} is not in your library snapshot.` + return null + } + if (p.min !== undefined && n < p.min) return `At least ${p.min}.` + if (p.max !== undefined && n > p.max) return `At most ${p.max}.` + return null +} + +/** Convert form strings to the typed params the server expects. */ +export function toParams(specs: ParamSpec[], values: ParamValues): Record { + const out: Record = {} + for (const p of specs) { + const raw = (values[p.name] ?? '').trim() + if (raw === '') { + if (p.default !== undefined) out[p.name] = p.default + continue + } + out[p.name] = p.type === 'string' ? raw : Number(raw) + } + return out +} + +export function initialValues(specs: ParamSpec[], preset: Record = {}): ParamValues { + return Object.fromEntries(specs.map((p) => [p.name, String(preset[p.name] ?? p.default ?? '')])) +} diff --git a/web/src/commands/runnerContext.ts b/web/src/commands/runnerContext.ts new file mode 100644 index 0000000..61d4370 --- /dev/null +++ b/web/src/commands/runnerContext.ts @@ -0,0 +1,14 @@ +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) => void + openPalette: () => void +} + +export const RunnerContext = createContext({ run: () => {}, openPalette: () => {} }) + +export function useRunner(): RunnerApi { + return useContext(RunnerContext) +} diff --git a/web/src/commands/useGameLookup.test.tsx b/web/src/commands/useGameLookup.test.tsx new file mode 100644 index 0000000..3fc05a8 --- /dev/null +++ b/web/src/commands/useGameLookup.test.tsx @@ -0,0 +1,52 @@ +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 }) => ( + {children} +) + +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)) + }) +}) diff --git a/web/src/commands/useGameLookup.ts b/web/src/commands/useGameLookup.ts new file mode 100644 index 0000000..b9bc6ca --- /dev/null +++ b/web/src/commands/useGameLookup.ts @@ -0,0 +1,27 @@ +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() + 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 }, + } +} diff --git a/web/src/components/BudgetPanel.tsx b/web/src/components/BudgetPanel.tsx index 6957839..4554fda 100644 --- a/web/src/components/BudgetPanel.tsx +++ b/web/src/components/BudgetPanel.tsx @@ -31,7 +31,7 @@ export function BudgetPanel({ demo }: Props) { } return ( -
+
{/* 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 ? ( @@ -49,6 +49,6 @@ export function BudgetPanel({ demo }: Props) { budgetSeconds={data.rules.budget_seconds} /> -
+ ) } diff --git a/web/src/components/PlannerView.test.tsx b/web/src/components/PlannerView.test.tsx new file mode 100644 index 0000000..0a5ef1e --- /dev/null +++ b/web/src/components/PlannerView.test.tsx @@ -0,0 +1,83 @@ +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) { + 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() + 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() + 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() + 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() + 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') + }) +}) diff --git a/web/src/components/PlannerView.tsx b/web/src/components/PlannerView.tsx index 26960f5..535484f 100644 --- a/web/src/components/PlannerView.tsx +++ b/web/src/components/PlannerView.tsx @@ -104,7 +104,7 @@ export function PlannerView() { onReset={() => setFilters(defaultFilters(dataset.defaults))} /> -
+
update({ search: s })} onToggleExclude={toggleExclude} /> -
+ ) diff --git a/web/src/index.css b/web/src/index.css index 0716efa..5400dac 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,36 +1,3 @@ -:root { - --bg: #211d1b; - --panel: #2b2624; - --panel-2: #38312e; - --card: #38312e; - --border: #463e3a; - --text: #ecEae9; - --muted: #aaa09a; - --heading: #ecEae9; - --accent: #b8862e; - --accent-2: #8a9a3c; - --warn: #e0a63c; - --danger: #e2585f; - --on-fill: #211d1b; - --accent-bg: rgba(184, 134, 46, 0.12); - --accent-border: rgba(184, 134, 46, 0.4); - --cat-1: #c57293; - --cat-2: #398fc0; - --cat-3: #c85a32; - --cat-4: #228736; - --cat-5: #8d58bb; - --cat-6: #686d2c; - --cat-none: #5d5551; - --radius-sm: 8px; - --radius-md: 12px; - --sp-1: 4px; - --sp-2: 8px; - --sp-3: 16px; - --sp-4: 24px; - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, 'SF Mono', Consolas, monospace; - color-scheme: dark; -} * { box-sizing: border-box; @@ -40,12 +7,12 @@ body { margin: 0; background: var(--bg); color: var(--text); - font: 16px/1.5 var(--sans); + font: 16px/1.5 var(--font-sans); } h1, h2 { - color: var(--heading); + color: var(--text); font-weight: 600; margin: 0; } @@ -55,7 +22,7 @@ h1 { } h2 { font-size: 16px; - margin-bottom: var(--sp-2); + margin-bottom: var(--space-2); } a { color: var(--accent); @@ -65,9 +32,9 @@ a:hover { text-decoration: underline; } code { - font-family: var(--mono); - background: var(--panel-2); - padding: var(--sp-1) var(--sp-2); + font-family: var(--font-mono); + background: var(--surface-2); + padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); font-size: 0.9em; } @@ -75,14 +42,14 @@ code { .app { max-width: 1400px; margin: 0 auto; - padding: var(--sp-4); + padding: var(--space-4); } .app-head { - margin-bottom: var(--sp-4); + margin-bottom: var(--space-4); } .sub { - color: var(--muted); - margin-top: var(--sp-2); + color: var(--text-muted); + margin-top: var(--space-2); } .status { @@ -94,10 +61,10 @@ code { color: var(--danger); } .hint { - color: var(--muted); + color: var(--text-muted); font-size: 13px; letter-spacing: 0.02em; - margin: var(--sp-2) 0 0; + margin: var(--space-2) 0 0; } /* ── Layout ── */ @@ -107,10 +74,10 @@ code { .layout { display: grid; grid-template-columns: 320px 1fr; - gap: var(--sp-4); + gap: var(--space-4); align-items: start; } -@media (max-width: 980px) { +@container planner (max-width: 900px) { .layout { grid-template-columns: 1fr; } @@ -118,27 +85,27 @@ code { .content { display: flex; flex-direction: column; - gap: var(--sp-3); + gap: var(--space-3); min-width: 0; } /* ── Filter panel ── */ .panel { - background: var(--panel); + background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius-md); - padding: var(--sp-3); + padding: var(--space-3); position: sticky; - top: var(--sp-3); + top: var(--space-3); } .panel-head { display: flex; align-items: center; justify-content: space-between; - margin-bottom: var(--sp-3); + margin-bottom: var(--space-3); } .field { - padding: var(--sp-2) 0; + padding: var(--space-2) 0; border-top: 1px solid var(--border); } .field > label { @@ -146,13 +113,13 @@ code { font-size: 13px; letter-spacing: 0.02em; font-weight: 500; - color: var(--heading); - margin-bottom: var(--sp-2); + color: var(--text); + margin-bottom: var(--space-2); } .field .val { float: right; color: var(--accent); - font-family: var(--mono); + font-family: var(--font-mono); font-weight: 400; } .field input[type='range'] { @@ -160,17 +127,17 @@ code { accent-color: var(--accent); } .subfield { - margin-top: var(--sp-2); + margin-top: var(--space-2); } .subfield.row { display: flex; - gap: var(--sp-2); + gap: var(--space-2); align-items: center; } .check { display: flex; align-items: center; - gap: var(--sp-2); + gap: var(--space-2); font-size: 13px; letter-spacing: 0.02em; color: var(--text); @@ -183,11 +150,11 @@ select, input[type='date'], input[type='search'] { width: 100%; - background: var(--panel-2); + background: var(--surface-2); border: 1px solid var(--border); color: var(--text); border-radius: var(--radius-sm); - padding: 7px var(--sp-2); + padding: 7px var(--space-2); font: inherit; } details summary { @@ -195,28 +162,28 @@ details summary { font-size: 13px; letter-spacing: 0.02em; font-weight: 500; - color: var(--heading); + color: var(--text); } details[open] summary { - margin-bottom: var(--sp-2); + margin-bottom: var(--space-2); } /* ── Buttons / segmented ── */ .segmented { display: flex; - gap: var(--sp-1); - background: var(--panel-2); - padding: var(--sp-1); + gap: var(--space-1); + background: var(--surface-2); + padding: var(--space-1); border-radius: var(--radius-sm); } .seg { flex: 1; border: none; background: transparent; - color: var(--muted); + color: var(--text-muted); /* Padding = outer(2x vertical, rule 22). Radius = outer 8 - the .segmented gap of 4 = 4 (rule 24), not a free choice. */ - padding: var(--sp-1) var(--sp-2); + padding: var(--space-1) var(--space-2); border-radius: 4px; cursor: pointer; font: inherit; @@ -231,8 +198,8 @@ details[open] summary { .ghost { background: transparent; border: 1px solid var(--border); - color: var(--muted); - padding: var(--sp-1) var(--sp-2); + color: var(--text-muted); + padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); cursor: pointer; font: inherit; @@ -240,66 +207,66 @@ details[open] summary { letter-spacing: 0.02em; } .ghost:hover { - color: var(--heading); + color: var(--text); border-color: var(--accent); } /* ── Summary ── */ .summary { - background: var(--panel); + background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius-md); - padding: var(--sp-3); + padding: var(--space-3); } .summary-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; - gap: var(--sp-2); - margin-bottom: var(--sp-3); + gap: var(--space-2); + margin-bottom: var(--space-3); } .big { font-size: 32px; font-weight: 700; - color: var(--heading); + color: var(--text); } .big-label { - margin-left: var(--sp-2); - color: var(--muted); + margin-left: var(--space-2); + color: var(--text-muted); } .parity { - color: var(--muted); + color: var(--text-muted); font-size: 13px; letter-spacing: 0.02em; } .parity .ok { - color: var(--accent-2); + color: var(--success); font-weight: 600; } .target-banner { - background: var(--accent-bg); - border: 1px solid var(--accent-border); + background: color-mix(in srgb, var(--accent) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); border-radius: var(--radius-sm); - padding: var(--sp-2) 12px; - margin-bottom: var(--sp-3); + padding: var(--space-2) 12px; + margin-bottom: var(--space-3); font-size: 14px; } .cards { display: grid; grid-template-columns: repeat(12, 1fr); - gap: var(--sp-3); + gap: var(--space-3); } .cards .card { grid-column: span 3; } -@media (max-width: 720px) { +@container planner (max-width: 640px) { .cards .card { grid-column: span 6; } } .card { - background: var(--card); + background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 14px; @@ -313,20 +280,20 @@ details[open] summary { } .card-title { font-weight: 600; - color: var(--heading); + color: var(--text); } .card-blurb { font-size: 12px; letter-spacing: 0.02em; - color: var(--muted); - margin-bottom: var(--sp-2); + color: var(--text-muted); + margin-bottom: var(--space-2); min-height: 28px; } .card-total { font-size: 20px; font-weight: 700; color: var(--accent); - font-family: var(--mono); + font-family: var(--font-mono); } .card-eta { font-size: 13px; @@ -335,24 +302,24 @@ details[open] summary { margin-top: 2px; } .presets { - margin-top: var(--sp-2); + margin-top: var(--space-2); border-top: 1px solid var(--border); - padding-top: var(--sp-2); + padding-top: var(--space-2); } .preset { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.02em; - color: var(--muted); + color: var(--text-muted); padding: 1px 0; } /* ── Player Speed Insight ── */ .player-insight { - margin-top: var(--sp-3); + margin-top: var(--space-3); padding: 14px; - background: var(--panel-2); + background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-md); } @@ -361,14 +328,14 @@ details[open] summary { } .player-insight-title { font-weight: 600; - color: var(--heading); - margin-bottom: var(--sp-2); + color: var(--text); + margin-bottom: var(--space-2); font-size: 14px; } .player-insight-empty { font-size: 13px; letter-spacing: 0.02em; - color: var(--muted); + color: var(--text-muted); margin: 0; } .player-insight-grid { @@ -380,47 +347,47 @@ details[open] summary { font-size: 13px; letter-spacing: 0.02em; color: var(--text); - margin-bottom: var(--sp-2); + margin-bottom: var(--space-2); } .player-insight-grid span:nth-child(odd) { - color: var(--muted); + color: var(--text-muted); } .player-insight-fast { - color: var(--accent-2); + color: var(--success); font-weight: 600; } .player-insight-slow { - color: var(--warn); + color: var(--warning); font-weight: 600; } .player-insight-style--faster_than_rush { - color: var(--accent-2); + color: var(--success); } .player-insight-style--slower_than_leisure { - color: var(--warn); + color: var(--warning); } .player-insight-estimate { border-top: 1px solid var(--border); - padding-top: var(--sp-2); + padding-top: var(--space-2); } .player-insight-estimate-total { font-size: 13px; letter-spacing: 0.02em; color: var(--text); - margin-bottom: var(--sp-2); + margin-bottom: var(--space-2); } .player-insight-estimate-hours { color: var(--accent); - font-family: var(--mono); + font-family: var(--font-mono); font-size: 16px; } /* ── Chart ── */ .chart { - background: var(--panel); + background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius-md); - padding: var(--sp-3); + padding: var(--space-3); } .chart-svg { width: 100%; @@ -437,14 +404,14 @@ details[open] summary { stroke-width: 2.5; } .chart-svg .area { - fill: var(--accent-bg); + fill: color-mix(in srgb, var(--accent) 12%, transparent); stroke: none; } .chart-svg .axis-label { - fill: var(--muted); + fill: var(--text-muted); font-size: 11px; letter-spacing: 0.02em; - font-family: var(--mono); + font-family: var(--font-mono); } .chart-svg .axis-label.end { text-anchor: end; @@ -455,17 +422,17 @@ details[open] summary { /* ── Table ── */ .table-wrap { - background: var(--panel); + background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius-md); - padding: var(--sp-3); + padding: var(--space-3); } .table-head { display: flex; align-items: center; justify-content: space-between; - gap: var(--sp-2); - margin-bottom: var(--sp-2); + gap: var(--space-2); + margin-bottom: var(--space-2); } .table-head input { max-width: 260px; @@ -489,10 +456,10 @@ table { thead th { position: sticky; top: 0; - background: var(--panel-2); - color: var(--heading); + background: var(--surface-2); + color: var(--text); text-align: left; - padding: var(--sp-2) 10px; + padding: var(--space-2) 10px; font-weight: 600; white-space: nowrap; z-index: 1; @@ -506,11 +473,11 @@ th.clickable { user-select: none; } tbody td { - padding: var(--sp-2) 10px; + padding: var(--space-2) 10px; border-top: 1px solid var(--border); } tbody tr:hover { - background: var(--panel-2); + background: var(--surface-2); } tr.excluded { opacity: 0.4; @@ -525,16 +492,16 @@ td.name { white-space: nowrap; } td.proton { - font-family: var(--mono); + font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.02em; - color: var(--muted); + color: var(--text-muted); } .badge { - margin-left: var(--sp-2); + margin-left: var(--space-2); font-size: 10px; letter-spacing: 0.02em; - background: var(--warn); + background: var(--warning); color: var(--on-fill); padding: 1px 5px; border-radius: var(--radius-sm); @@ -545,14 +512,14 @@ td.proton { Same shape as .segmented/.seg, but page-level rather than inside a panel. */ .tabs { display: flex; - gap: var(--sp-1); - padding: var(--sp-3) var(--sp-4) 0; + gap: var(--space-1); + padding: var(--space-3) var(--space-4) 0; } .tab { border: none; background: transparent; - color: var(--muted); - padding: var(--sp-2) var(--sp-3); + color: var(--text-muted); + padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm) var(--radius-sm) 0 0; cursor: pointer; font: inherit; @@ -567,24 +534,24 @@ td.proton { /* --- Gaming budget ------------------------------------------------------ */ .budget { - padding: var(--sp-4); + padding: 0; display: flex; flex-direction: column; - gap: var(--sp-4); + gap: var(--space-4); } .budget-today, .budget-live, .budget-rules { - background: var(--panel); + background: var(--surface-1); border: 1px solid var(--border); border-radius: var(--radius-md); - padding: var(--sp-3); + padding: var(--space-3); } .budget-hero { font-size: 40px; line-height: 1.1; - margin: var(--sp-2) 0; - color: var(--muted); + margin: var(--space-2) 0; + color: var(--text-muted); } .budget-hero strong { color: var(--text); @@ -592,7 +559,7 @@ td.proton { } .budget-meter { height: 10px; - background: var(--panel-2); + background: var(--surface-2); border-radius: var(--radius-sm); overflow: hidden; } @@ -601,14 +568,14 @@ td.proton { background: var(--accent); } .budget-meter--warn .budget-meter-fill { - background: var(--warn); + background: var(--warning); } .budget-meter--blocked .budget-meter-fill { background: var(--danger); } .budget-badge { display: inline-block; - padding: var(--sp-1) var(--sp-2); + padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); font-weight: 600; } @@ -618,20 +585,20 @@ td.proton { } .budget-verdict { font-weight: 600; - color: var(--muted); + color: var(--text-muted); } .budget-verdict--engaged { - color: var(--accent-2); + color: var(--success); } .budget-rules-list { display: grid; grid-template-columns: max-content 1fr; - gap: var(--sp-1) var(--sp-3); + gap: var(--space-1) var(--space-3); margin: 0; font-size: 14px; } .budget-rules-list dt { - color: var(--muted); + color: var(--text-muted); } .budget-rules-list dd { margin: 0; @@ -655,13 +622,13 @@ td.proton { fill: var(--cat-6); } .chart-svg .seg.other { - fill: var(--muted); + fill: var(--text-muted); } .chart-svg .seg.unattributed { - fill: var(--cat-none); + fill: color-mix(in srgb, var(--text-muted) 30%, var(--surface-2)); } .chart-svg .budget-line { - stroke: var(--muted); + stroke: var(--text-muted); stroke-width: 1; stroke-dasharray: 4 4; } @@ -670,24 +637,24 @@ td.proton { .chart-legend { display: flex; flex-wrap: wrap; - gap: var(--sp-2) var(--sp-3); - margin: var(--sp-2) 0 0; + gap: var(--space-2) var(--space-3); + margin: var(--space-2) 0 0; padding: 0; list-style: none; - color: var(--muted); + color: var(--text-muted); font-size: 0.875rem; } .chart-legend li { display: flex; align-items: center; - gap: var(--sp-1); + gap: var(--space-1); } .swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; - background: var(--cat-none); + background: color-mix(in srgb, var(--text-muted) 30%, var(--surface-2)); flex: none; } .swatch.cat-1 { @@ -709,28 +676,28 @@ td.proton { background: var(--cat-6); } .swatch.other { - background: var(--muted); + background: var(--text-muted); } .swatch.unattributed { - background: var(--cat-none); + background: color-mix(in srgb, var(--text-muted) 30%, var(--surface-2)); } .budget-subhead { - margin: var(--sp-3) 0 var(--sp-2); + margin: var(--space-3) 0 var(--space-2); font-size: 0.95rem; - color: var(--heading); + color: var(--text); } .budget-games { margin: 0; padding: 0; list-style: none; display: grid; - gap: var(--sp-1); + gap: var(--space-1); } .budget-games li { display: grid; grid-template-columns: 12px 1fr auto auto; align-items: center; - gap: var(--sp-2); + gap: var(--space-2); } .budget-game-name { color: var(--text); @@ -739,11 +706,11 @@ td.proton { white-space: nowrap; } .budget-game-time { - font-family: var(--mono); + font-family: var(--font-mono); color: var(--text); } .budget-game-share { - color: var(--muted); + color: var(--text-muted); min-width: 3ch; text-align: right; } diff --git a/web/src/jobs/GamePrompt.test.tsx b/web/src/jobs/GamePrompt.test.tsx new file mode 100644 index 0000000..bc6c593 --- /dev/null +++ b/web/src/jobs/GamePrompt.test.tsx @@ -0,0 +1,113 @@ +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 => ({ + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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((r) => (release = r))), + }) + renderClient() + 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() + }) +}) diff --git a/web/src/jobs/GamePrompt.tsx b/web/src/jobs/GamePrompt.tsx new file mode 100644 index 0000000..d3da1d9 --- /dev/null +++ b/web/src/jobs/GamePrompt.tsx @@ -0,0 +1,49 @@ +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(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 ( +
{ + e.preventDefault() + if (selected !== null) submit(selected) + }} + > +

+ {prompt.message} · {appIds.size} games can be picked +

+ + {send.error && } +
+ + +
+ + ) +} diff --git a/web/src/jobs/JobProgress.nolive.test.tsx b/web/src/jobs/JobProgress.nolive.test.tsx new file mode 100644 index 0000000..f866901 --- /dev/null +++ b/web/src/jobs/JobProgress.nolive.test.tsx @@ -0,0 +1,17 @@ +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() + expect(screen.queryByRole('region')).toBeNull() + expect(document.querySelector('section')).toBeNull() + }) +}) diff --git a/web/src/jobs/JobProgress.test.tsx b/web/src/jobs/JobProgress.test.tsx new file mode 100644 index 0000000..ae17c6b --- /dev/null +++ b/web/src/jobs/JobProgress.test.tsx @@ -0,0 +1,194 @@ +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[0]; cancellable?: boolean; compact?: boolean; extra?: Record } = {}) { + const id = `jp-${++n}` + const routes: Record = { + [`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() + const es = FakeEventSource.last + let seq = 0 + const send = (type: string, body: Record) => 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((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() + }) + }) +}) diff --git a/web/src/jobs/JobProgress.tsx b/web/src/jobs/JobProgress.tsx new file mode 100644 index 0000000..f952861 --- /dev/null +++ b/web/src/jobs/JobProgress.tsx @@ -0,0 +1,116 @@ +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 = new Set(['succeeded', 'failed', 'cancelled']) +const FINISHED_LABEL: Partial> = { 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(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 ( +
+
+

{job ? commandLabel(job.command) : 'Job'}

+ + + {job && duration(job.started_at, job.ended_at, now)} + {!finished && ( + + {live.connected ? 'Live' : 'Reconnecting'} + + )} + +
+ +
+ {finished ? FINISHED_LABEL[state] : waiting ? 'Waiting for your answer' : (progress?.label ?? 'Starting…')} + {!finished && progress && progress.total !== null && ( + + Step {progress.step} of {progress.total} + + )} +
+ + {!finished && ( +
+ {progress?.item ?? ' '} + {remaining !== null ? `about ${clock(remaining)} left` : waiting ? 'paused' : progress?.total === null ? 'working…' : ''} +
+ )} + + {live.prompt && state === 'waiting_input' && } + + {live.result && ( + + {live.result.summary} + + )} + + {cancel.error && } + {(spec?.cancellable || waiting) && !finished && ( +
+ +
+ )} + +
+ Log ({logCount} lines) +
    + {live.logs.map((l) => ( +
  1. + {l.message} +
  2. + ))} + {logCount === 0 &&
  3. {finished ? 'No output.' : 'No output yet.'}
  4. } +
+
+
+ ) +} diff --git a/web/src/jobs/ProgressBar.test.tsx b/web/src/jobs/ProgressBar.test.tsx new file mode 100644 index 0000000..52587f8 --- /dev/null +++ b/web/src/jobs/ProgressBar.test.tsx @@ -0,0 +1,45 @@ +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() + expect(screen.getByText(text)).toHaveClass(`pill-${tone}`) + }) +}) + +describe('ProgressBar', () => { + it('is a determinate progressbar with a percentage', () => { + render() + 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() + 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() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '100') + rerender() + expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '0') + }) +}) diff --git a/web/src/jobs/ProgressBar.tsx b/web/src/jobs/ProgressBar.tsx new file mode 100644 index 0000000..d88be7b --- /dev/null +++ b/web/src/jobs/ProgressBar.tsx @@ -0,0 +1,53 @@ +import type { JobState } from '../api/jobContract' + +const STATE_LABEL: Record = { + queued: 'Queued', + running: 'Running', + waiting_input: 'Needs your input', + succeeded: 'Succeeded', + failed: 'Failed', + cancelled: 'Cancelled', +} + +const STATE_TONE: Record = { + queued: 'neutral', + running: 'accent', + waiting_input: 'warning', + succeeded: 'success', + failed: 'danger', + cancelled: 'neutral', +} + +export function StateBadge({ state }: { state: JobState }) { + return {STATE_LABEL[state]} +} + +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 ( +
+
+
+ ) +} diff --git a/web/src/jobs/PromptForm.test.tsx b/web/src/jobs/PromptForm.test.tsx new file mode 100644 index 0000000..4df74d5 --- /dev/null +++ b/web/src/jobs/PromptForm.test.tsx @@ -0,0 +1,118 @@ +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 => ({ + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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((r) => (release = r)) }) + renderClient() + 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((r) => (release = r)) }) + renderClient() + await userEvent.click(screen.getByRole('button', { name: 'Answer' })) + expect(await screen.findByRole('button', { name: 'Sending…' })).toBeDisabled() + await act(async () => release()) + }) +}) diff --git a/web/src/jobs/PromptForm.tsx b/web/src/jobs/PromptForm.tsx new file mode 100644 index 0000000..118e20f --- /dev/null +++ b/web/src/jobs/PromptForm.tsx @@ -0,0 +1,87 @@ +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 + return +} + +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 ( +
+

{prompt.message}

+ {prompt.kind === 'choice' && ( +
+ {prompt.message} + {prompt.options?.map((o, i) => ( + + ))} +
+ )} + {prompt.kind === 'text' && ( + setValue(e.target.value)} + /> + )} + {prompt.kind === 'phrase' && ( + + )} + {send.error && } +
+ {prompt.kind === 'confirm' ? ( + <> + + + + ) : ( + + )} +
+ + ) +} diff --git a/web/src/jobs/hub.test.ts b/web/src/jobs/hub.test.ts new file mode 100644 index 0000000..339c574 --- /dev/null +++ b/web/src/jobs/hub.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { FakeEventSource, installEventSource } from '../test/harness' +import { hub } from './hub' + +let n = 0 +const newId = () => `hub-job-${++n}` +const ts = '2026-10-10T10:00:00Z' + +describe('JobHub', () => { + beforeEach(() => { + installEventSource() + document.head.innerHTML = '' + }) + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + hub.onTerminal = () => {} + }) + + it('reports an empty job before anything arrives', () => { + expect(hub.get(newId())).toMatchObject({ lastSeq: 0, state: null, logs: [], connected: false }) + }) + + it('opens one stream per job, sharing it between watchers', () => { + const id = newId() + const a = hub.watch(id) + const b = hub.watch(id) + expect(FakeEventSource.all).toHaveLength(1) + expect(FakeEventSource.last.url).toBe(`/api/jobs/${id}/events?token=t%26k&after=0`) + a() + expect(FakeEventSource.last.closed).toBe(false) + b() + expect(FakeEventSource.last.closed).toBe(true) + }) + + it('marks the stream connected on open and disconnected on release', () => { + const id = newId() + const unwatch = hub.watch(id) + FakeEventSource.last.onopen?.() + expect(hub.get(id).connected).toBe(true) + unwatch() + expect(hub.get(id).connected).toBe(false) + }) + + it('notifies subscribers, until they unsubscribe', () => { + const id = newId() + const listener = vi.fn() + const off = hub.subscribe(listener) + const unwatch = hub.watch(id) + FakeEventSource.last.emit('log', { seq: 1, ts, type: 'log', level: 'info', message: 'hi' }) + expect(listener).toHaveBeenCalledTimes(1) + off() + FakeEventSource.last.emit('log', { seq: 2, ts, type: 'log', level: 'info', message: 'again' }) + expect(listener).toHaveBeenCalledTimes(1) + unwatch() + }) + + it('folds state, log, progress, prompt and result events', () => { + const id = newId() + const unwatch = hub.watch(id) + const es = FakeEventSource.last + es.emit('state', { seq: 1, ts, type: 'state', state: 'running' }) + es.emit('log', { seq: 2, ts, type: 'log', level: 'warning', message: 'careful' }) + es.emit('progress', { seq: 3, ts, type: 'progress', step: 1, total: 4, label: 'Scanning', item: 'A', eta_seconds: 9 }) + const first = hub.get(id).startedProgressAt + es.emit('progress', { seq: 4, ts, type: 'progress', step: 2, total: 4, label: 'Scanning' }) + expect(hub.get(id).startedProgressAt).toBe(first) + expect(hub.get(id).progress).toMatchObject({ step: 2, label: 'Scanning' }) + es.emit('prompt', { seq: 5, ts, type: 'prompt', prompt_id: 'q1', kind: 'confirm', message: 'ok?' }) + es.emit('state', { seq: 6, ts, type: 'state', state: 'waiting_input' }) + expect(hub.get(id).prompt?.prompt_id).toBe('q1') + es.emit('state', { seq: 7, ts, type: 'state', state: 'running' }) + expect(hub.get(id).prompt).toBeNull() + es.emit('prompt', { seq: 8, ts, type: 'prompt', prompt_id: 'q2', kind: 'text', message: 'name?' }) + es.emit('result', { seq: 9, ts, type: 'result', ok: true, summary: 'done' }) + const live = hub.get(id) + expect(live).toMatchObject({ state: 'running', prompt: null, lastSeq: 9 }) + expect(live.logs.map((l) => l.message)).toEqual(['careful']) + expect(live.result?.summary).toBe('done') + unwatch() + }) + + it('keeps only the most recent 500 log lines', () => { + const id = newId() + const unwatch = hub.watch(id) + for (let i = 1; i <= 505; i++) FakeEventSource.last.emit('log', { seq: i, ts, type: 'log', level: 'info', message: `m${i}` }) + const logs = hub.get(id).logs + expect(logs).toHaveLength(500) + expect(logs[0].message).toBe('m6') + unwatch() + }) + + it('accepts unnamed messages, drops replays and ignores junk', () => { + const id = newId() + const unwatch = hub.watch(id) + const es = FakeEventSource.last + es.emitRaw(JSON.stringify({ seq: 1, ts, type: 'log', level: 'info', message: 'one' })) + es.emitRaw(JSON.stringify({ seq: 1, ts, type: 'log', level: 'info', message: 'dup' })) + es.emitRaw('{not json') + expect(hub.get(id).logs.map((l) => l.message)).toEqual(['one']) + unwatch() + }) + + it('tells onTerminal when a job ends, by state or by result', () => { + const onTerminal = vi.fn() + hub.onTerminal = onTerminal + const id = newId() + const unwatch = hub.watch(id) + FakeEventSource.last.emit('state', { seq: 1, ts, type: 'state', state: 'running' }) + expect(onTerminal).not.toHaveBeenCalled() + FakeEventSource.last.emit('state', { seq: 2, ts, type: 'state', state: 'failed' }) + FakeEventSource.last.emit('result', { seq: 3, ts, type: 'result', ok: false, summary: 'x' }) + expect(onTerminal).toHaveBeenCalledTimes(2) + expect(onTerminal).toHaveBeenCalledWith(id) + unwatch() + }) + + it('has a harmless default for onTerminal', async () => { + vi.resetModules() + const { hub: fresh } = await import('./hub') + const unwatch = fresh.watch('fresh-job') + FakeEventSource.last.emit('state', { seq: 1, ts, type: 'state', state: 'succeeded' }) + expect(fresh.get('fresh-job').state).toBe('succeeded') + unwatch() + }) + + it('answered hides only the prompt it answered', () => { + const id = newId() + const unwatch = hub.watch(id) + FakeEventSource.last.emit('prompt', { seq: 1, ts, type: 'prompt', prompt_id: 'q1', kind: 'text', message: '?' }) + hub.answered(id, 'other') + expect(hub.get(id).prompt).not.toBeNull() + hub.answered(id, 'q1') + expect(hub.get(id).prompt).toBeNull() + unwatch() + }) + + it('does not reopen a stream for a job that already finished', () => { + const id = newId() + const first = hub.watch(id) + FakeEventSource.last.emit('state', { seq: 1, ts, type: 'state', state: 'succeeded' }) + FakeEventSource.last.emit('result', { seq: 2, ts, type: 'result', ok: true, summary: 's' }) + first() + const opened = FakeEventSource.all.length + hub.watch(id)() + expect(FakeEventSource.all).toHaveLength(opened) + }) + + describe('reconnecting', () => { + beforeEach(() => vi.useFakeTimers()) + + it('reopens after the last seen event, with growing backoff, reset by a good open', () => { + const id = newId() + const unwatch = hub.watch(id) + FakeEventSource.last.emit('log', { seq: 7, ts, type: 'log', level: 'info', message: 'x' }) + FakeEventSource.last.onerror?.() + expect(FakeEventSource.last.closed).toBe(true) + expect(hub.get(id).connected).toBe(false) + vi.advanceTimersByTime(999) + expect(FakeEventSource.all).toHaveLength(1) + vi.advanceTimersByTime(1) + expect(FakeEventSource.all).toHaveLength(2) + expect(FakeEventSource.last.url).toMatch(/after=7$/) + FakeEventSource.last.onerror?.() // second failure: 2 s + vi.advanceTimersByTime(1999) + expect(FakeEventSource.all).toHaveLength(2) + vi.advanceTimersByTime(1) + expect(FakeEventSource.all).toHaveLength(3) + FakeEventSource.last.onopen?.() + FakeEventSource.last.onerror?.() // backoff is back to 1 s + vi.advanceTimersByTime(1000) + expect(FakeEventSource.all).toHaveLength(4) + unwatch() + }) + + it('caps the backoff at ten seconds', () => { + const id = newId() + const unwatch = hub.watch(id) + for (let i = 0; i < 6; i++) { + FakeEventSource.last.onerror?.() + vi.advanceTimersByTime(10_000) + } + expect(FakeEventSource.all).toHaveLength(7) + FakeEventSource.last.onerror?.() + vi.advanceTimersByTime(9_999) + expect(FakeEventSource.all).toHaveLength(7) + vi.advanceTimersByTime(1) + expect(FakeEventSource.all).toHaveLength(8) + unwatch() + }) + + it('does not retry a finished job', () => { + const id = newId() + const unwatch = hub.watch(id) + FakeEventSource.last.emit('state', { seq: 1, ts, type: 'state', state: 'cancelled' }) + FakeEventSource.last.onerror?.() + vi.advanceTimersByTime(60_000) + expect(FakeEventSource.all).toHaveLength(1) + unwatch() + }) + + it('does not retry once nobody watches, and cancels a pending retry', () => { + const id = newId() + const unwatch = hub.watch(id) + const es = FakeEventSource.last + es.onerror?.() // retry scheduled, source already null + unwatch() // clears the timer + vi.advanceTimersByTime(60_000) + expect(FakeEventSource.all).toHaveLength(1) + es.onerror?.() // a late error on the dead source + vi.advanceTimersByTime(60_000) + expect(FakeEventSource.all).toHaveLength(1) + }) + }) +}) diff --git a/web/src/jobs/hub.ts b/web/src/jobs/hub.ts new file mode 100644 index 0000000..a285dfd --- /dev/null +++ b/web/src/jobs/hub.ts @@ -0,0 +1,158 @@ +// Live job state from `GET /api/jobs/{id}/events` (SSE). One EventSource per +// watched job, shared by every component showing it. On a dropped stream the +// source is closed (so the browser's own retry, which knows nothing about +// `after=`, never fires) and reopened with `after=`. + +import { sessionToken } from '../api/client' +import { + ENDPOINTS, +} from '../api/contract' +import type { JobEvent, JobProgress, JobState } from '../api/jobContract' + +export type PromptEvent = Extract +export type LogEvent = Extract +export type ResultEvent = Extract + +export interface LiveJob { + lastSeq: number + state: JobState | null + progress: JobProgress | null + logs: LogEvent[] + /** Prompt the job is blocked on, until answered here or resumed. */ + prompt: PromptEvent | null + result: ResultEvent | null + connected: boolean + /** Seconds since epoch of the first progress event, for rate display. */ + startedProgressAt: number | null +} + +const EMPTY: LiveJob = { + lastSeq: 0, state: null, progress: null, logs: [], prompt: null, result: null, + connected: false, startedProgressAt: null, +} +const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']) +const EVENT_TYPES = ['state', 'log', 'progress', 'prompt', 'result'] as const +const MAX_LOGS = 500 + +function reduce(live: LiveJob, e: JobEvent): LiveJob { + const next: LiveJob = { ...live, lastSeq: e.seq } + switch (e.type) { + case 'state': + next.state = e.state + if (e.state !== 'waiting_input') next.prompt = null + break + case 'log': + next.logs = [...live.logs, e].slice(-MAX_LOGS) + break + case 'progress': { + const { step, total, label, item, eta_seconds } = e + next.progress = { step, total, label, item, eta_seconds } + next.startedProgressAt ??= Date.now() / 1000 + break + } + case 'prompt': + next.prompt = e + break + case 'result': + next.result = e + next.prompt = null + break + } + return next +} + +interface Stream { + refs: number + source: EventSource | null + retry: ReturnType | null + backoff: number +} + +class JobHub { + private live = new Map() + private streams = new Map() + private listeners = new Set<() => void>() + onTerminal: (id: string) => void = () => {} + + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + get(id: string): LiveJob { + return this.live.get(id) ?? EMPTY + } + + /** Start following a job; returns the matching unwatch. Ref-counted. */ + watch(id: string): () => void { + const s = this.streams.get(id) ?? { refs: 0, source: null, retry: null, backoff: 1000 } + s.refs += 1 + this.streams.set(id, s) + if (s.refs === 1) this.open(id, s) + return () => { + s.refs -= 1 + if (s.refs > 0) return + s.source?.close() + if (s.retry) clearTimeout(s.retry) + this.streams.delete(id) + this.patch(id, { connected: false }) + } + } + + /** Hide an answered prompt immediately, before the job's state event. */ + answered(id: string, promptId: string): void { + if (this.get(id).prompt?.prompt_id === promptId) this.patch(id, { prompt: null }) + } + + private open(id: string, s: Stream): void { + const live = this.get(id) + if (live.state && TERMINAL.has(live.state) && live.result) return + const url = `${ENDPOINTS.jobEvents(id)}?token=${encodeURIComponent(sessionToken())}&after=${live.lastSeq}` + const source = new EventSource(url) + s.source = source + const onEvent = (msg: MessageEvent) => this.ingest(id, msg.data) + source.onmessage = onEvent + for (const t of EVENT_TYPES) source.addEventListener(t, onEvent) + source.onopen = () => { + s.backoff = 1000 + this.patch(id, { connected: true }) + } + source.onerror = () => { + source.close() + s.source = null + this.patch(id, { connected: false }) + 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.backoff = Math.min(s.backoff * 2, 10_000) + } + } + } + + private ingest(id: string, data: string): void { + let e: JobEvent + try { + e = JSON.parse(data) as JobEvent + } catch { + return + } + const live = this.get(id) + if (e.seq <= live.lastSeq) return + const next = reduce(live, e) + this.live.set(id, next) + this.emit() + if ((e.type === 'state' && TERMINAL.has(e.state)) || e.type === 'result') this.onTerminal(id) + } + + private patch(id: string, p: Partial): void { + this.live.set(id, { ...this.get(id), ...p }) + this.emit() + } + + private emit(): void { + for (const l of this.listeners) l() + } +} + +export const hub = new JobHub() diff --git a/web/src/jobs/useLiveJob.test.tsx b/web/src/jobs/useLiveJob.test.tsx new file mode 100644 index 0000000..06ff456 --- /dev/null +++ b/web/src/jobs/useLiveJob.test.tsx @@ -0,0 +1,35 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { FakeEventSource, installEventSource } from '../test/harness' +import { useLiveJob } from './useLiveJob' + +describe('useLiveJob', () => { + beforeEach(() => { + installEventSource() + document.head.innerHTML = '' + }) + + it('is null without a job and opens no stream', () => { + const { result } = renderHook(() => useLiveJob(null)) + expect(result.current).toBeNull() + expect(FakeEventSource.all).toHaveLength(0) + }) + + it('follows a job while mounted and re-renders on events', () => { + const { result, unmount } = renderHook(() => useLiveJob('live-1')) + expect(result.current?.state).toBeNull() + act(() => FakeEventSource.last.emit('state', { seq: 1, ts: 't', type: 'state', state: 'running' })) + expect(result.current?.state).toBe('running') + unmount() + expect(FakeEventSource.last.closed).toBe(true) + }) + + it('switches streams when the id changes', () => { + const { rerender } = renderHook(({ id }) => useLiveJob(id), { initialProps: { id: 'live-2' } }) + const first = FakeEventSource.last + rerender({ id: 'live-3' }) + expect(first.closed).toBe(true) + expect(FakeEventSource.last.url).toContain('/live-3/') + vi.clearAllMocks() + }) +}) diff --git a/web/src/jobs/useLiveJob.ts b/web/src/jobs/useLiveJob.ts new file mode 100644 index 0000000..f884354 --- /dev/null +++ b/web/src/jobs/useLiveJob.ts @@ -0,0 +1,9 @@ +import { useCallback, useEffect, useSyncExternalStore } from 'react' +import { hub, type LiveJob } from './hub' + +/** Follow a job's SSE stream for as long as the caller is mounted. */ +export function useLiveJob(id: string | null): LiveJob | null { + useEffect(() => (id ? hub.watch(id) : undefined), [id]) + const snapshot = useCallback(() => (id ? hub.get(id) : null), [id]) + return useSyncExternalStore(hub.subscribe, snapshot) +} diff --git a/web/src/library/GameBrowser.lag.test.tsx b/web/src/library/GameBrowser.lag.test.tsx new file mode 100644 index 0000000..f618e65 --- /dev/null +++ b/web/src/library/GameBrowser.lag.test.tsx @@ -0,0 +1,40 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { queryClient } from '../api/queries' +import { makeLibGame } from '../test/fixtures' +import { stubApi } from '../test/harness' +import { GameBrowser } from './GameBrowser' + +// The grid trails the search box by design (useDeferredValue). Pin the +// trailing value so the lag can be held open and released deterministically. +const lag = vi.hoisted(() => ({ value: undefined as string | undefined })) +vi.mock('react', async (importOriginal) => { + const real = await importOriginal() + return { ...real, useDeferredValue: (v: T): T => (lag.value ?? v) as T } +}) + +describe('GameBrowser while the grid lags the search box', () => { + it('focuses a suggested card once the grid shows it', async () => { + stubApi({ + 'GET /api/library': { + games: [makeLibGame({ app_id: 1, name: 'Hollow Knight' }), makeLibGame({ app_id: 2, name: 'The Witcher' })], + }, + }) + lag.value = 'hollow' + render( + + + , + ) + const box = await screen.findByRole('combobox', { name: 'Search your games' }) + await userEvent.type(box, 'witcher') + await userEvent.keyboard('{Enter}') + // The grid still shows only "Hollow Knight": nothing to focus yet. + expect(screen.queryByRole('button', { name: /The Witcher/ })).toBeNull() + lag.value = undefined + await userEvent.type(box, ' ') + await waitFor(() => expect(screen.getByRole('button', { name: /The Witcher/ })).toHaveFocus()) + }) +}) diff --git a/web/src/library/GameBrowser.test.tsx b/web/src/library/GameBrowser.test.tsx new file mode 100644 index 0000000..fc54678 --- /dev/null +++ b/web/src/library/GameBrowser.test.tsx @@ -0,0 +1,247 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, 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 { queryClient } from '../api/queries' +import { makeLibGame } from '../test/fixtures' +import { apiError, stubApi } from '../test/harness' +import { GameBrowser, type PickMode } from './GameBrowser' + +const GAMES = [ + makeLibGame({ app_id: 1, name: 'The Witcher', achievements_unlocked: 10, last_played: 100, hltb_hours: 30 }), + makeLibGame({ app_id: 2, name: 'The Witcher 2', achievements_unlocked: 2, last_played: 300, hltb_hours: 20, installed: true }), + makeLibGame({ app_id: 3, name: 'The Witcher 3', achievements_unlocked: 5, last_played: null, hltb_hours: null, ineligible_reason: 'Already started' }), + makeLibGame({ app_id: 4, name: 'Age of Empires III', achievements_unlocked: 5, last_played: 200, hltb_hours: 10 }), + makeLibGame({ app_id: 5, name: 'Hollow Knight', achievements_unlocked: 1, last_played: 50, hltb_hours: 5 }), +] +const withLibrary = (games = GAMES) => stubApi({ 'GET /api/library': { games } }) + +function renderBrowser(pick?: PickMode) { + return render( + + + , + ) +} +const titles = () => screen.queryAllByRole('button').filter((b) => b.dataset.appId).map((b) => b.querySelector('.game-title')!.textContent) +const card = (name: string) => screen.getByRole('button', { name: new RegExp(name) }) +const search = () => screen.getByRole('combobox', { name: 'Search your games' }) + +describe('GameBrowser', () => { + beforeEach(() => { + withLibrary() + }) + afterEach(() => vi.unstubAllGlobals()) + + it('shows the library sorted by name, with a count', async () => { + renderBrowser() + expect(screen.getByText(/Loading your library/)).toBeInTheDocument() + await screen.findByText('5 of 5 games') + expect(titles()).toEqual(['Age of Empires III', 'Hollow Knight', 'The Witcher', 'The Witcher 2', 'The Witcher 3']) + }) + + it('explains a failed load', async () => { + stubApi({ 'GET /api/library': apiError(500, 'op_failed', 'cannot read library') }) + renderBrowser() + expect(await screen.findByText('cannot read library')).toBeInTheDocument() + expect(screen.queryByText(/Loading/)).toBeNull() + }) + + it('says so when nothing matches', async () => { + withLibrary([]) + renderBrowser() + expect(await screen.findByText('No game matches.')).toBeInTheDocument() + expect(screen.getByText('0 of 0 games')).toBeInTheDocument() + }) + + it('sorts by the chosen key', async () => { + renderBrowser() + await screen.findByText('5 of 5 games') + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Sort' }), 'recent') + expect(titles()[0]).toBe('The Witcher 2') + await userEvent.selectOptions(screen.getByRole('combobox', { name: 'Sort' }), 'hltb') + expect(titles()[0]).toBe('Hollow Knight') + }) + + it('filters', async () => { + renderBrowser() + await screen.findByText('5 of 5 games') + await userEvent.click(screen.getByRole('checkbox', { name: 'Installed only' })) + expect(titles()).toEqual(['The Witcher 2']) + await userEvent.click(screen.getByRole('checkbox', { name: 'Installed only' })) + await userEvent.click(screen.getByRole('checkbox', { name: 'Pickable only' })) + expect(titles()).not.toContain('The Witcher 3') + await userEvent.click(screen.getByRole('checkbox', { name: 'Hide completed' })) + expect(titles()).not.toContain('The Witcher') + }) + + it('"wicher" finds the Witcher titles by relevance and locks the sort', async () => { + renderBrowser() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'wicher') + await screen.findByText(/by relevance/) + expect(titles()).toEqual(['The Witcher', 'The Witcher 2', 'The Witcher 3']) + expect(screen.getByRole('combobox', { name: 'Sort' })).toBeDisabled() + }) + + it('"witcher 3" does not return Age of Empires III', async () => { + renderBrowser() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'witcher 3') + await waitFor(() => expect(titles()[0]).toBe('The Witcher 3')) + expect(titles()).not.toContain('Age of Empires III') + }) + + it('an app id finds that game', async () => { + renderBrowser() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), '5') + await waitFor(() => expect(titles()[0]).toBe('Hollow Knight')) + }) + + it('Enter on a suggestion moves focus to its card', async () => { + renderBrowser() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'hollow') + await userEvent.keyboard('{Enter}') + await waitFor(() => expect(card('Hollow Knight')).toHaveFocus()) + expect(card('Hollow Knight')).toHaveAttribute('tabindex', '0') + }) + + it('a missing card is focused once the grid catches up', async () => { + renderBrowser() + await screen.findByText('5 of 5 games') + // Choosing from the list while the grid still shows a stale result. + await userEvent.type(search(), 'hollow') + fireEvent.keyDown(search(), { key: 'Enter' }) + await waitFor(() => expect(document.activeElement).toBe(card('Hollow Knight'))) + }) + + describe('keyboard in the grid', () => { + beforeEach(() => { + withLibrary(Array.from({ length: 12 }, (_, i) => makeLibGame({ app_id: i + 1, name: `Game ${String(i).padStart(2, '0')}` }))) + // jsdom lays nothing out; only the grid is told it has three columns. + const real = window.getComputedStyle.bind(window) + vi.spyOn(window, 'getComputedStyle').mockImplementation((el, pseudo) => + el.classList.contains('game-grid') ? ({ gridTemplateColumns: '1fr 1fr 1fr' } as CSSStyleDeclaration) : real(el, pseudo), + ) + }) + afterEach(() => vi.restoreAllMocks()) + + const press = async (key: string) => { + fireEvent.keyDown(document.activeElement!, { key }) + await waitFor(() => expect(document.activeElement).toHaveAttribute('data-app-id')) + } + const at = () => (document.activeElement as HTMLElement).querySelector('.game-title')!.textContent + + it('moves by cell, row, page and to the ends (roving tabindex)', async () => { + renderBrowser() + await screen.findByText('12 of 12 games') + card('Game 00').focus() + await press('ArrowRight') + await waitFor(() => expect(at()).toBe('Game 01')) + await press('ArrowDown') + await waitFor(() => expect(at()).toBe('Game 04')) + await press('ArrowLeft') + await waitFor(() => expect(at()).toBe('Game 03')) + await press('ArrowUp') + await waitFor(() => expect(at()).toBe('Game 00')) + await press('PageDown') + await waitFor(() => expect(at()).toBe('Game 09')) + await press('PageUp') + await waitFor(() => expect(at()).toBe('Game 00')) + await press('End') + await waitFor(() => expect(at()).toBe('Game 11')) + expect(card('Game 11')).toHaveAttribute('tabindex', '0') + expect(card('Game 00')).toHaveAttribute('tabindex', '-1') + await press('Home') + await waitFor(() => expect(at()).toBe('Game 00')) + await press('ArrowUp') // clamps at the first card + await waitFor(() => expect(at()).toBe('Game 00')) + }) + + it('leaves other keys alone', async () => { + renderBrowser() + await screen.findByText('12 of 12 games') + const e = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true }) + card('Game 00').dispatchEvent(e) + expect(e.defaultPrevented).toBe(false) + }) + + it('ignores navigation keys when nothing is shown', async () => { + renderBrowser() + await screen.findByText('12 of 12 games') + await userEvent.type(search(), 'qqqqqq') + await screen.findByText('No game matches.') + const e = new KeyboardEvent('keydown', { key: 'End', bubbles: true, cancelable: true }) + document.querySelector('.game-grid')!.dispatchEvent(e) + expect(e.defaultPrevented).toBe(false) + }) + + it('a click makes that card the tab stop', async () => { + renderBrowser() + await screen.findByText('12 of 12 games') + await userEvent.click(card('Game 05')) + expect(card('Game 05')).toHaveAttribute('tabindex', '0') + }) + + it('falls back to one column when the layout is unknown', async () => { + vi.restoreAllMocks() // jsdom computes no grid columns + renderBrowser() + await screen.findByText('12 of 12 games') + card('Game 00').focus() + await press('ArrowDown') + await waitFor(() => expect(at()).toBe('Game 01')) + }) + }) + + describe('pick mode', () => { + const pickable = new Set([1, 2]) + const setup = (selected: number | null = null) => { + const onSelect = vi.fn() + renderBrowser({ appIds: pickable, selected, onSelect }) + return onSelect + } + + it('selects eligible cards only, and never submits', async () => { + const onSelect = setup() + await screen.findByText('5 of 5 games') + await userEvent.click(card('The Witcher 2')) + expect(onSelect).toHaveBeenCalledWith(2) + onSelect.mockClear() + await userEvent.click(card('Hollow Knight')) + await userEvent.click(card('The Witcher 3')) + expect(onSelect).not.toHaveBeenCalled() + }) + + it('shows why a game is not offered', async () => { + setup(2) + await screen.findByText('5 of 5 games') + expect(card('The Witcher 3')).toHaveTextContent('Already started') + expect(card('Hollow Knight')).toHaveTextContent('Not offered here') + expect(card('The Witcher 2')).toHaveAttribute('aria-pressed', 'true') + }) + + it('"pickable only" keeps just the ids the job sent', async () => { + setup() + await screen.findByText('5 of 5 games') + await userEvent.click(screen.getByRole('checkbox', { name: 'Pickable only' })) + expect(titles()).toEqual(['The Witcher', 'The Witcher 2']) + }) + + it('choosing a suggestion selects an eligible game but only focuses an ineligible one', async () => { + const onSelect = setup() + await screen.findByText('5 of 5 games') + await userEvent.type(search(), 'hollow') + await userEvent.keyboard('{Enter}') + expect(onSelect).not.toHaveBeenCalled() + await waitFor(() => expect(card('Hollow Knight')).toHaveFocus()) + await userEvent.clear(search()) + await userEvent.type(search(), 'witcher 2') + await userEvent.keyboard('{Enter}') + expect(onSelect).toHaveBeenCalledWith(2) + const grid = screen.getByRole('group', { name: 'Games' }) + await waitFor(() => expect(within(grid).getByRole('button', { name: /The Witcher 2/ })).toHaveFocus()) + }) + }) +}) diff --git a/web/src/library/GameBrowser.tsx b/web/src/library/GameBrowser.tsx new file mode 100644 index 0000000..db43c13 --- /dev/null +++ b/web/src/library/GameBrowser.tsx @@ -0,0 +1,135 @@ +import { useQuery } from '@tanstack/react-query' +import { useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { LibraryGame } from '../api/contract' +import { q } from '../api/queries' +import { ErrorNotice, Loading } from '../ui/Notice' +import { buildIndex, search } from './fuzzy' +import { GameCard } from './GameCard' +import { SearchBox } from './SearchBox' +import { filterGames, NO_FILTERS, SORTS, sortGames, type Filters, type SortKey } from './view' + +const SUGGESTIONS = 8 + +/** Pick mode: only `appIds` are selectable; the caller's button submits. */ +export interface PickMode { + appIds: ReadonlySet + selected: number | null + onSelect: (appId: number) => void +} + +const FILTER_LABELS: [keyof Filters, string][] = [ + ['hideCompleted', 'Hide completed'], + ['installedOnly', 'Installed only'], + ['pickableOnly', 'Pickable only'], +] + +/** Grid columns as laid out right now (for ↑/↓ moving one row). */ +function columns(grid: HTMLElement): number { + return Math.max(1, getComputedStyle(grid).gridTemplateColumns.split(' ').filter(Boolean).length) +} + +/** + * Every owned game as a cover grid: fuzzy search with suggestions, sort, + * filters. The grid is one tab stop; arrows, PageUp/PageDown and Home/End + * move between cards (roving tabindex). + */ +export function GameBrowser({ pick }: { pick?: PickMode }) { + const { data, error } = useQuery(q.library) + const games = useMemo(() => data?.games ?? [], [data]) + const index = useMemo(() => buildIndex(games), [games]) + const [query, setQuery] = useState('') + const [sort, setSort] = useState('name') + const [filters, setFilters] = useState(NO_FILTERS) + const [active, setActive] = useState(0) + const [focusId, setFocusId] = useState(null) + const grid = useRef(null) + const deferred = useDeferredValue(query) + + const reason = (g: LibraryGame): string | null => + pick ? (pick.appIds.has(g.app_id) ? null : (g.ineligible_reason ?? 'Not offered here')) : g.ineligible_reason + const filtered = filterGames(games, filters, (g) => reason(g) === null) + const visible = deferred.trim() ? search(filtered, index, deferred) : sortGames(filtered, sort) + const suggestions = query.trim() ? search(filtered, index, query).slice(0, SUGGESTIONS) : [] + const current = Math.min(active, Math.max(0, visible.length - 1)) + + useEffect(() => { + if (focusId === null) return + const card = grid.current?.querySelector(`[data-app-id="${focusId}"]`) + if (!card) return + setActive(Number(card.dataset.index)) + card.focus() + setFocusId(null) + }, [focusId, visible]) + + const choose = (g: LibraryGame) => { + if (pick && reason(g) === null) pick.onSelect(g.app_id) + setFocusId(g.app_id) + } + + const onGridKey = (e: KeyboardEvent) => { + const cols = columns(e.currentTarget) + const page = cols * 3 + const moves: Record = { + ArrowRight: 1, ArrowLeft: -1, ArrowDown: cols, ArrowUp: -cols, + PageDown: page, PageUp: -page, Home: -Infinity, End: Infinity, + } + if (!(e.key in moves) || visible.length === 0) return + e.preventDefault() + const next = Math.min(visible.length - 1, Math.max(0, current + moves[e.key])) + setFocusId(visible[next].app_id) + } + + return ( +
+
+ + +
+ Filters + {FILTER_LABELS.map(([key, label]) => ( + + ))} +
+ + {data && `${visible.length} of ${games.length} games${deferred.trim() ? ' · by relevance' : ''}`} + +
+ {error && } + {!data && !error && } + {data && visible.length === 0 &&

No game matches.

} +
+ {visible.map((g, i) => ( + { + setActive(i) + // Select only: assigning can uninstall other games, so + // it takes the explicit "Pick …" button, never a double-click. + if (reason(g) === null) pick.onSelect(g.app_id) + } + : () => setActive(i) + } + /> + ))} +
+
+ ) +} diff --git a/web/src/library/GameCard.test.tsx b/web/src/library/GameCard.test.tsx new file mode 100644 index 0000000..dd3c480 --- /dev/null +++ b/web/src/library/GameCard.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { makeLibGame } from '../test/fixtures' +import { GameCard } from './GameCard' + +function renderCard(over: Partial> = {}) { + const onActivate = vi.fn() + const props = { + game: makeLibGame({ app_id: 42, name: 'Hollow Knight' }), + index: 3, + reason: null, + selected: false, + focusable: true, + pickMode: false, + onActivate, + ...over, + } + render() + return { onActivate, card: screen.getByRole('button') } +} + +describe('GameCard', () => { + it('is a button carrying its id and index, with title and meta line', () => { + const { card } = renderCard() + expect(card).toHaveAttribute('data-app-id', '42') + expect(card).toHaveAttribute('data-index', '3') + expect(card).toHaveTextContent('Hollow Knight') + expect(card).toHaveTextContent('50%') + }) + + it('is a roving tab stop', () => { + expect(renderCard().card).toHaveAttribute('tabindex', '0') + }) + + it('is out of the tab order when not the active card', () => { + expect(renderCard({ focusable: false }).card).toHaveAttribute('tabindex', '-1') + }) + + it('exposes pressed state only in pick mode', () => { + expect(renderCard().card).not.toHaveAttribute('aria-pressed') + }) + + it('exposes pressed and disabled in pick mode, with the reason shown', () => { + const { card } = renderCard({ pickMode: true, selected: true, reason: 'Already finished' }) + expect(card).toHaveAttribute('aria-pressed', 'true') + expect(card).toHaveAttribute('aria-disabled', 'true') + expect(card).toHaveClass('selected', 'ineligible') + expect(card).toHaveTextContent('Already finished') + }) + + it('is not aria-disabled in pick mode when pickable', () => { + const { card } = renderCard({ pickMode: true }) + expect(card).not.toHaveAttribute('aria-disabled') + expect(card).toHaveAttribute('aria-pressed', 'false') + }) + + it('shows badges for assigned and installed', () => { + const { card } = renderCard({ game: makeLibGame({ assigned: true, installed: true }) }) + expect(card).toHaveTextContent('assigned') + expect(card).toHaveTextContent('installed') + }) + + it('shows only the badge that applies, and none otherwise', () => { + renderCard({ game: makeLibGame({ installed: true }) }) + expect(screen.getByText('installed')).toBeInTheDocument() + expect(screen.queryByText('assigned')).toBeNull() + }) + + it('has no badge row for a plain game', () => { + renderCard() + expect(document.querySelector('.game-badges')).toBeNull() + }) + + it('reports clicks', async () => { + const { card, onActivate } = renderCard() + await userEvent.click(card) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('lazy-loads the art and falls back to a title tile when it 404s', () => { + const { card } = renderCard() + const img = card.querySelector('img')! + expect(img).toHaveAttribute('src', '/api/art/42') + expect(img).toHaveAttribute('loading', 'lazy') + fireEvent.error(img) + expect(card.querySelector('img')).toBeNull() + expect(card.querySelector('.cover-tile')).toHaveTextContent('Hollow Knight') + }) +}) diff --git a/web/src/library/GameCard.tsx b/web/src/library/GameCard.tsx new file mode 100644 index 0000000..d17844b --- /dev/null +++ b/web/src/library/GameCard.tsx @@ -0,0 +1,73 @@ +import { useState } from 'react' +import { ENDPOINTS, type LibraryGame } from '../api/contract' +import { metaLine } from './view' + +/** Portrait cover, lazily loaded; a title tile when the server has none. */ +function Cover({ game }: { game: LibraryGame }) { + const [failed, setFailed] = useState(false) + if (failed) { + return ( + + ) + } + return ( + setFailed(true)} + /> + ) +} + +/** + * One tile of the grid. A button in both modes so it is a focus stop; in + * browse mode it only takes focus, in pick mode it selects. `reason` greys + * it out and says why it cannot be picked. + */ +export function GameCard({ + game, + index, + reason, + selected, + focusable, + pickMode, + onActivate, +}: { + game: LibraryGame + index: number + reason: string | null + selected: boolean + focusable: boolean + pickMode: boolean + onActivate: () => void +}) { + const cls = ['game-card', reason ? 'ineligible' : '', selected ? 'selected' : ''].filter(Boolean).join(' ') + return ( + + ) +} diff --git a/web/src/library/SearchBox.test.tsx b/web/src/library/SearchBox.test.tsx new file mode 100644 index 0000000..5095f74 --- /dev/null +++ b/web/src/library/SearchBox.test.tsx @@ -0,0 +1,131 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import type { LibraryGame } from '../api/contract' +import { makeLibGame } from '../test/fixtures' +import { SearchBox } from './SearchBox' + +const A = makeLibGame({ app_id: 1, name: 'Alpha' }) +const B = makeLibGame({ app_id: 2, name: 'Beta', ineligible_reason: 'Already finished' }) +const C = makeLibGame({ app_id: 3, name: 'Gamma' }) + +function Harness({ suggestions = [A, B, C], onChoose = () => {} }: { suggestions?: LibraryGame[]; onChoose?: (g: LibraryGame) => void }) { + const [value, setValue] = useState('') + return ( + g.ineligible_reason} onChoose={onChoose} /> + ) +} + +const box = () => screen.getByRole('combobox', { name: 'Search your games' }) + +describe('SearchBox', () => { + it('lists suggestions once typing starts, with reason or meta line', async () => { + render() + expect(screen.queryByRole('option')).toBeNull() + await userEvent.type(box(), 'a') + const options = screen.getAllByRole('option') + expect(options).toHaveLength(3) + expect(options[1]).toHaveTextContent('Already finished') + expect(options[0]).toHaveTextContent('50%') + expect(box()).toHaveAttribute('aria-expanded', 'true') + }) + + it('stays closed when there are no suggestions', async () => { + render() + await userEvent.type(box(), 'zz') + expect(box()).toHaveAttribute('aria-expanded', 'false') + }) + + it('moves through the list with the arrows, wrapping, and announces the active one', async () => { + render() + await userEvent.type(box(), 'a') + await userEvent.keyboard('{ArrowDown}') + expect(box()).toHaveAttribute('aria-activedescendant', expect.stringMatching(/-0$/)) + await userEvent.keyboard('{ArrowDown}{ArrowDown}{ArrowDown}') + expect(screen.getAllByRole('option')[0]).toHaveAttribute('aria-selected', 'true') + await userEvent.keyboard('{ArrowUp}') + expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true') + }) + + it('starts from the last suggestion on ArrowUp, and reopens a closed list', async () => { + render() + await userEvent.type(box(), 'a') + await userEvent.keyboard('{Escape}') + expect(box()).toHaveAttribute('aria-expanded', 'false') + await userEvent.keyboard('{ArrowUp}') + expect(box()).toHaveAttribute('aria-expanded', 'true') + expect(screen.getAllByRole('option')[2]).toHaveAttribute('aria-selected', 'true') + }) + + it('ignores arrows with no suggestions', async () => { + render() + await userEvent.type(box(), 'a') + await userEvent.keyboard('{ArrowDown}{ArrowUp}') + expect(box()).not.toHaveAttribute('aria-activedescendant') + }) + + it('Enter chooses the highlighted suggestion, else the first, and closes the list', async () => { + const onChoose = vi.fn() + render() + await userEvent.type(box(), 'a') + await userEvent.keyboard('{Enter}') + expect(onChoose).toHaveBeenLastCalledWith(A) + expect(box()).toHaveAttribute('aria-expanded', 'false') + await userEvent.type(box(), 'b') + await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}') + expect(onChoose).toHaveBeenLastCalledWith(B) + }) + + it('falls back to the first suggestion when the highlight is out of range', () => { + const onChoose = vi.fn() + const { rerender } = render() + fireEvent.change(box(), { target: { value: 'a' } }) + fireEvent.keyDown(box(), { key: 'ArrowUp' }) // highlights the last of three + rerender() + fireEvent.keyDown(box(), { key: 'Enter' }) + expect(onChoose).toHaveBeenCalledWith(A) + }) + + it('Enter does nothing while the list is hidden', async () => { + const onChoose = vi.fn() + render() + await userEvent.type(box(), '{Enter}') + expect(onChoose).not.toHaveBeenCalled() + }) + + it('Escape closes the list first, then clears the text, then lets the key go', async () => { + const outer = vi.fn() + render( +
+ +
, + ) + await userEvent.type(box(), 'abc') + await userEvent.keyboard('{Escape}') + expect(box()).toHaveAttribute('aria-expanded', 'false') + expect(box()).toHaveValue('abc') + await userEvent.keyboard('{Escape}') + expect(box()).toHaveValue('') + outer.mockClear() + await userEvent.keyboard('{Escape}') + expect(outer).toHaveBeenCalled() + }) + + it('opens on focus and closes on blur', async () => { + render() + await userEvent.type(box(), 'a') + await userEvent.tab() + expect(box()).toHaveAttribute('aria-expanded', 'false') + await userEvent.click(box()) + expect(box()).toHaveAttribute('aria-expanded', 'true') + }) + + it('chooses a suggestion on mousedown, before blur can close the list', async () => { + const onChoose = vi.fn() + render() + await userEvent.type(box(), 'a') + fireEvent.mouseDown(screen.getByRole('option', { name: /Gamma/ })) + expect(onChoose).toHaveBeenCalledWith(C) + }) +}) diff --git a/web/src/library/SearchBox.tsx b/web/src/library/SearchBox.tsx new file mode 100644 index 0000000..9fd4fb3 --- /dev/null +++ b/web/src/library/SearchBox.tsx @@ -0,0 +1,104 @@ +import { useId, useState, type KeyboardEvent } from 'react' +import type { LibraryGame } from '../api/contract' +import { metaLine } from './view' + +/** + * Library search: an ARIA combobox. Typing filters the grid behind it and + * lists the top suggestions; ↑/↓ move through them, Enter chooses the + * highlighted one (or the first), Escape closes the list, then clears. + */ +export function SearchBox({ + value, + onChange, + suggestions, + reason, + onChoose, +}: { + value: string + onChange: (v: string) => void + suggestions: LibraryGame[] + /** Why a game cannot be picked here (pick mode differs from the library). */ + reason: (g: LibraryGame) => string | null + onChoose: (g: LibraryGame) => void +}) { + const listId = useId() + const [open, setOpen] = useState(false) + const [active, setActive] = useState(-1) + const shown = open && value.trim() !== '' && suggestions.length > 0 + + const choose = (g: LibraryGame) => { + setOpen(false) + setActive(-1) + onChoose(g) + } + + const onKeyDown = (e: KeyboardEvent) => { + const n = suggestions.length + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (n === 0) return + e.preventDefault() + setOpen(true) + const step = e.key === 'ArrowDown' ? 1 : -1 + setActive((i) => (i < 0 && step < 0 ? n - 1 : (i + step + n) % n)) + } else if (e.key === 'Enter') { + if (!shown) return + e.preventDefault() + choose(suggestions[active >= 0 && active < n ? active : 0]) + } else if (e.key === 'Escape') { + if (shown) { + e.preventDefault() + e.stopPropagation() + setOpen(false) + } else if (value) { + e.preventDefault() + e.stopPropagation() + onChange('') + } + } + } + + return ( +
+ = 0 ? `${listId}-${active}` : undefined} + placeholder="Search your games (typos are fine)…" + value={value} + onChange={(e) => { + onChange(e.target.value) + setOpen(true) + setActive(-1) + }} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={onKeyDown} + /> + +
+ ) +} diff --git a/web/src/library/fuzzy.test.ts b/web/src/library/fuzzy.test.ts new file mode 100644 index 0000000..7983ffd --- /dev/null +++ b/web/src/library/fuzzy.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { buildIndex, editDistance, normalise, score, search } from './fuzzy' + +const GAMES = [ + { app_id: 1, name: 'The Witcher: Enhanced Edition' }, + { app_id: 2, name: 'The Witcher 2: Assassins of Kings' }, + { app_id: 3, name: 'The Witcher 3: Wild Hunt' }, + { app_id: 4, name: 'Age of Empires III' }, + { app_id: 5, name: 'Hollow Knight' }, + { app_id: 6, name: 'X-COM: Enemy Unknown' }, + { app_id: 7, name: 'I Am Bread' }, + { app_id: 8, name: "Baldur's Gate & Friends" }, + { app_id: 9, name: 'Pokémon Stadium 2' }, +] +const INDEX = buildIndex(GAMES) +const find = (q: string) => search(GAMES, INDEX, q).map((g) => g.name) + +describe('normalise', () => { + it('strips case, accents and punctuation', () => { + expect(normalise('Pokémon Stadium!').text).toBe('pokemon stadium') + expect(normalise("Baldur's Gate & Co").tokens).toEqual(['baldurs', 'gate', 'and', 'co']) + }) + + it('splits letter/digit runs', () => { + expect(normalise('witcher3').tokens).toEqual(['witcher', '3']) + expect(normalise('3d').tokens).toEqual(['3', 'd']) + }) + + it('converts roman numerals but never a lone i', () => { + expect(normalise('Witcher III').tokens).toEqual(['witcher', '3']) + expect(normalise('I Am Bread').tokens).toEqual(['i', 'am', 'bread']) + }) + + it('keeps the unconverted compact form', () => { + expect(normalise('X-COM').compact).toBe('xcom') + }) +}) + +describe('editDistance', () => { + it('is 0 for equal words and counts edits', () => { + expect(editDistance('abc', 'abc', 2)).toBe(0) + expect(editDistance('wicher', 'witcher', 2)).toBe(1) + expect(editDistance('abcd', 'abdc', 2)).toBe(1) // transposition + }) + + it('gives up past max', () => { + expect(editDistance('a', 'abcdef', 2)).toBe(3) // length gap + expect(editDistance('aaaa', 'bbbb', 1)).toBe(2) // row minimum + }) +}) + +describe('search', () => { + it('finds Witcher titles for a typo', () => { + const hits = find('wicher') + expect(hits).toContain('The Witcher 3: Wild Hunt') + expect(hits).toContain('The Witcher 2: Assassins of Kings') + expect(hits).toContain('The Witcher: Enhanced Edition') + }) + + it('matches roman and arabic numerals both ways', () => { + expect(find('witcher iii')[0]).toBe('The Witcher 3: Wild Hunt') + expect(find('empires 3')).toContain('Age of Empires III') + expect(find('age of empires iii')[0]).toBe('Age of Empires III') + }) + + it('does not return Age of Empires III for "witcher 3"', () => { + const hits = find('witcher 3') + expect(hits[0]).toBe('The Witcher 3: Wild Hunt') + expect(hits).not.toContain('Age of Empires III') + }) + + it('finds "xcom" in "X-COM" through the compact form', () => { + expect(find('xcom')).toEqual(['X-COM: Enemy Unknown']) + }) + + it('joins split words when no single word is close enough', () => { + const items = [{ app_id: 60, name: 'Spider-Man Remastered' }] + expect(search(items, buildIndex(items), 'spiderman')).toEqual(items) + }) + + it('does not treat a lone "i" as a numeral', () => { + expect(find('i')).toEqual(['I Am Bread']) + expect(find('i am')).toEqual(['I Am Bread']) + }) + + it('matches an app id exactly, ranked first', () => { + expect(find('5')[0]).toBe('Hollow Knight') + expect(find(' 3 ')[0]).toBe('The Witcher 3: Wild Hunt') + }) + + it('shows a partial match with one word beyond repair', () => { + expect(find('hollow nite')).toEqual(['Hollow Knight']) + }) + + it('rejects a lone number or short word as a partial match', () => { + expect(find('witcher 99')).not.toContain('Age of Empires III') + expect(find('qqqq zz')).toEqual([]) + }) + + it('matches substrings of three or more letters', () => { + expect(find('aldu')).toEqual(["Baldur's Gate & Friends"]) + expect(find('nd')).toEqual([]) + expect(find('wit')).toHaveLength(3) // prefix of a later word + expect(find('zz')).toEqual([]) + }) + + it('returns nothing for an empty or punctuation-only query', () => { + expect(find('')).toEqual([]) + expect(find('---')).toEqual([]) + }) + + it('breaks score ties by name', () => { + const items = [ + { app_id: 20, name: 'Zed Game' }, + { app_id: 21, name: 'Alpha Game' }, + ] + const hits = search(items, buildIndex(items), 'game').map((g) => g.name) + expect(hits).toEqual(['Alpha Game', 'Zed Game']) + }) + + it('normalises on the fly for an item missing from the index', () => { + const extra = [{ app_id: 50, name: 'Hollow Knight' }] + expect(search(extra, new Map(), 'hollow')).toEqual(extra) + }) +}) + +describe('score', () => { + it('ranks a prefix match above a fuzzy one', () => { + const q = normalise('hollow') + expect(score(q, normalise('Hollow Knight'))).toBeGreaterThan(score(q, normalise('Hallow Night'))) + }) + + it('requires an exact match for numbers', () => { + expect(score(normalise('2'), normalise('Game 3'))).toBe(0) + }) + + it('is 0 without query words', () => { + expect(score(normalise(''), normalise('Anything'))).toBe(0) + }) +}) diff --git a/web/src/library/fuzzy.ts b/web/src/library/fuzzy.ts new file mode 100644 index 0000000..d1f3ff4 --- /dev/null +++ b/web/src/library/fuzzy.ts @@ -0,0 +1,129 @@ +// Typo-tolerant game-name search for the library browser. +// +// Names and queries go through the same normalisation (case, accents, +// punctuation, "witcher3" → "witcher 3", roman numerals → arabic), then each +// query word must match some name word exactly, as a prefix, as a substring, +// or within a small edit distance ("wicher" → "witcher"). A lone "i" is never +// read as 1: it is a word ("I Am Bread") far more often than a numeral. + +const ROMAN: Record = { + ii: '2', iii: '3', iv: '4', v: '5', vi: '6', vii: '7', viii: '8', ix: '9', x: '10', + xi: '11', xii: '12', xiii: '13', xiv: '14', xv: '15', xvi: '16', xx: '20', +} + +/** Lower-case words without accents or punctuation, digit runs split off. */ +function words(text: string): string[] { + return text + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/['’]/g, '') + .replace(/([a-z])(\d)|(\d)([a-z])/g, '$1$3 $2$4') + .split(/[^a-z0-9]+/) + .filter(Boolean) +} + +export interface Searchable { + tokens: string[] + /** Words before numeral conversion, joined: "xcom" still finds "X-COM". */ + compact: string + text: string +} + +export function normalise(text: string): Searchable { + const raw = words(text) + const tokens = raw.map((w) => ROMAN[w] ?? w) + return { tokens, compact: raw.join(''), text: tokens.join(' ') } +} + +/** Optimal-string-alignment distance, giving up once it exceeds `max`. */ +export function editDistance(a: string, b: string, max: number): number { + if (Math.abs(a.length - b.length) > max) return max + 1 + let prev2: number[] = [] + let prev = Array.from({ length: b.length + 1 }, (_, j) => j) + for (let i = 1; i <= a.length; i++) { + const cur = [i] + let rowMin = i + for (let j = 1; j <= b.length; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1 + let v = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost) + if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { + v = Math.min(v, prev2[j - 2] + 1) + } + cur.push(v) + rowMin = Math.min(rowMin, v) + } + if (rowMin > max) return max + 1 + prev2 = prev + prev = cur + } + return prev[b.length] +} + +const allowedTypos = (word: string) => (word.length < 4 ? 0 : word.length < 7 ? 1 : 2) + +/** How well one query word matches one name word, 0 when it does not. */ +function wordScore(q: string, n: string): number { + if (n === q) return 1 + if (n.startsWith(q)) return 0.85 + if (q.length >= 3 && n.includes(q)) return 0.6 + const max = /^\d+$/.test(q) ? 0 : allowedTypos(q) + if (max === 0) return 0 + // Whole word, or the start of a longer one the user is still typing. + const d = Math.min(editDistance(q, n, max), editDistance(q, n.slice(0, q.length), max)) + return d <= max ? 0.7 - 0.15 * d : 0 +} + +/** Relevance of `name` for `query` (higher is better), or 0 for no match. */ +export function score(query: Searchable, name: Searchable): number { + if (query.tokens.length === 0) return 0 + if (name.text.startsWith(query.text)) return 100 - name.tokens.length + let total = 0 + let missed = 0 + let longestHit = 0 + let longestMiss = 0 + for (const q of query.tokens) { + let best = 0 + for (const n of name.tokens) best = Math.max(best, wordScore(q, n)) + if (best === 0) { + missed++ + longestMiss = Math.max(longestMiss, q.length) + } else if (!/^\d+$/.test(q)) { + longestHit = Math.max(longestHit, q.length) + } + total += best + } + const words = query.tokens.length + if (missed === 0) return (80 * total) / words - name.tokens.length * 0.5 + if (query.compact.length >= 3 && name.compact.includes(query.compact)) return 40 + // "hollow nite": the main word found, a shorter one beyond repair. Shown, + // ranked low; a lone "3" or "of" matching is not enough. + const partial = words >= 2 && missed * 2 <= words && longestHit >= 4 && longestHit >= longestMiss + return partial ? (30 * total) / words : 0 +} + +/** + * Items matching `query`, best first (ties by name). An all-digit query also + * matches an app id exactly, ranked above everything. + */ +export function search( + items: readonly T[], + index: ReadonlyMap, + query: string, +): T[] { + const q = normalise(query) + const id = /^\d+$/.test(query.trim()) ? Number(query.trim()) : null + const hits: [T, number][] = [] + for (const item of items) { + const s = item.app_id === id ? 1000 : score(q, index.get(item.app_id) ?? normalise(item.name)) + if (s > 0) hits.push([item, s]) + } + hits.sort((a, b) => b[1] - a[1] || a[0].name.localeCompare(b[0].name)) + return hits.map(([item]) => item) +} + +/** Normalised names by app id, built once per library load. */ +export function buildIndex(items: readonly { app_id: number; name: string }[]): Map { + return new Map(items.map((i) => [i.app_id, normalise(i.name)])) +} diff --git a/web/src/library/view.test.ts b/web/src/library/view.test.ts new file mode 100644 index 0000000..206984f --- /dev/null +++ b/web/src/library/view.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' +import { makeLibGame } from '../test/fixtures' +import { completion, filterGames, isCompleted, metaLine, NO_FILTERS, sortGames } from './view' + +const g = makeLibGame + +describe('completion', () => { + it('is a percentage, or null without achievements', () => { + expect(completion(g({ achievements_total: 8, achievements_unlocked: 2 }))).toBe(25) + expect(completion(g({ achievements_total: 0 }))).toBeNull() + }) + + it('isCompleted needs achievements and all of them', () => { + expect(isCompleted(g({ achievements_total: 4, achievements_unlocked: 4 }))).toBe(true) + expect(isCompleted(g({ achievements_total: 4, achievements_unlocked: 3 }))).toBe(false) + expect(isCompleted(g({ achievements_total: 0, achievements_unlocked: 0 }))).toBe(false) + }) +}) + +describe('sortGames', () => { + const a = g({ app_id: 1, name: 'Bravo', last_played: 100, hltb_hours: 30, achievements_unlocked: 1 }) + const b = g({ app_id: 2, name: 'Alpha', last_played: 200, hltb_hours: 5, achievements_unlocked: 9 }) + const c = g({ app_id: 3, name: 'Charlie', last_played: null, hltb_hours: null, achievements_total: 0 }) + const d = g({ app_id: 4, name: 'Delta', last_played: null, hltb_hours: null, achievements_total: 0 }) + const names = (key: Parameters[1]) => sortGames([c, a, d, b], key).map((x) => x.name) + + it('sorts by name', () => expect(names('name')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) + it('sorts recent first, never-played last', () => + expect(names('recent')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) + it('sorts by completion descending, unknown last', () => + expect(names('completion')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) + it('sorts shortest HLTB first, unknown last', () => + expect(names('hltb')).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta'])) + + it('does not mutate its input', () => { + const input = [b, a] + sortGames(input, 'name') + expect(input).toEqual([b, a]) + }) +}) + +describe('filterGames', () => { + const done = g({ app_id: 1, achievements_total: 2, achievements_unlocked: 2, installed: true }) + const open = g({ app_id: 2, installed: false }) + const all = [done, open] + const pickable = (x: { app_id: number }) => x.app_id === 2 + + it('keeps everything without filters', () => { + expect(filterGames(all, NO_FILTERS, pickable)).toEqual(all) + }) + it('hides completed', () => { + expect(filterGames(all, { ...NO_FILTERS, hideCompleted: true }, pickable)).toEqual([open]) + }) + it('keeps installed only', () => { + expect(filterGames(all, { ...NO_FILTERS, installedOnly: true }, pickable)).toEqual([done]) + }) + it('keeps pickable only', () => { + expect(filterGames(all, { ...NO_FILTERS, pickableOnly: true }, pickable)).toEqual([open]) + }) +}) + +describe('metaLine', () => { + it('joins percentage, HLTB and playtime', () => { + expect(metaLine(g({ achievements_total: 8, achievements_unlocked: 3, hltb_hours: 12, playtime_minutes: 192 }))) + .toBe('37% · 12 h HLTB · 3.2 h played') + }) + it('uses one decimal under 10 h', () => { + expect(metaLine(g({ hltb_hours: 4.25, playtime_minutes: 0 }))).toBe('50% · 4.3 h HLTB') + }) + it('says so without achievements, HLTB or playtime', () => { + expect(metaLine(g({ achievements_total: 0, hltb_hours: null, playtime_minutes: 0 }))).toBe('No achievements') + }) +}) diff --git a/web/src/library/view.ts b/web/src/library/view.ts new file mode 100644 index 0000000..a57153d --- /dev/null +++ b/web/src/library/view.ts @@ -0,0 +1,72 @@ +// Sorting, filtering and labels for the library browser. Pure functions, so +// the grid component only wires state to them. + +import type { LibraryGame } from '../api/contract' + +export type SortKey = 'name' | 'recent' | 'completion' | 'hltb' + +export const SORTS: { key: SortKey; label: string }[] = [ + { key: 'name', label: 'Name' }, + { key: 'recent', label: 'Recently played' }, + { key: 'completion', label: 'Completion' }, + { key: 'hltb', label: 'Shortest (HLTB)' }, +] + +export interface Filters { + hideCompleted: boolean + installedOnly: boolean + pickableOnly: boolean +} + +export const NO_FILTERS: Filters = { hideCompleted: false, installedOnly: false, pickableOnly: false } + +/** Achievement percentage, or null when there is no achievement data. */ +export function completion(g: LibraryGame): number | null { + return g.achievements_total > 0 ? (100 * g.achievements_unlocked) / g.achievements_total : null +} + +export const isCompleted = (g: LibraryGame) => + g.achievements_total > 0 && g.achievements_unlocked >= g.achievements_total + +/** Missing values sort last whichever way the key runs. */ +function byNullable(a: number | null, b: number | null, desc: boolean): number { + if (a === null || b === null) return (a === null ? 1 : 0) - (b === null ? 1 : 0) + return desc ? b - a : a - b +} + +const COMPARE: Record number> = { + name: () => 0, + recent: (a, b) => byNullable(a.last_played, b.last_played, true), + completion: (a, b) => byNullable(completion(a), completion(b), true), + hltb: (a, b) => byNullable(a.hltb_hours, b.hltb_hours, false), +} + +export function sortGames(games: readonly LibraryGame[], key: SortKey): LibraryGame[] { + return [...games].sort((a, b) => COMPARE[key](a, b) || a.name.localeCompare(b.name)) +} + +export function filterGames( + games: readonly LibraryGame[], + f: Filters, + pickable: (g: LibraryGame) => boolean, +): LibraryGame[] { + return games.filter( + (g) => + !(f.hideCompleted && isCompleted(g)) && + !(f.installedOnly && !g.installed) && + !(f.pickableOnly && !pickable(g)), + ) +} + +/** "45% · 12 h HLTB · 3.2 h played", the card's one meta line. */ +export function metaLine(g: LibraryGame): string { + const pct = completion(g) + const parts = [pct === null ? 'No achievements' : `${Math.floor(pct)}%`] + if (g.hltb_hours !== null) parts.push(`${hours(g.hltb_hours)} HLTB`) + if (g.playtime_minutes > 0) parts.push(`${hours(g.playtime_minutes / 60)} played`) + return parts.join(' · ') +} + +function hours(h: number): string { + return h >= 10 ? `${Math.round(h)} h` : `${h.toFixed(1)} h` +} diff --git a/web/src/main.tsx b/web/src/main.tsx index bef5202..d96d9d6 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,6 +1,11 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import './styles/tokens.css' import './index.css' +import './styles/app.css' +import './styles/controls.css' +import './styles/screens.css' +import './styles/library.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( diff --git a/web/src/router.tsx b/web/src/router.tsx new file mode 100644 index 0000000..5bb6ee1 --- /dev/null +++ b/web/src/router.tsx @@ -0,0 +1,59 @@ +// Code-based TanStack Router tree: one route per section, plus job detail. +// No file-based generator, so there is no generated file to keep in sync. + +import { createRootRoute, createRoute, createRouter } from '@tanstack/react-router' +import { Backlog } from './screens/Backlog' +import { Dashboard } from './screens/Dashboard' +import { Gaming } from './screens/Gaming' +import { JobDetail, Jobs } from './screens/Jobs' +import { Library } from './screens/Library' +import { NotFound } from './screens/NotFound' +import { Picks } from './screens/Picks' +import { Setup } from './screens/Setup' +import { Store } from './screens/Store' +import { System } from './screens/System' +import { AppShell } from './shell/AppShell' + +const root = createRootRoute({ component: AppShell, notFoundComponent: NotFound }) + +const page = (path: TPath, component: () => React.ReactNode) => + createRoute({ getParentRoute: () => root, path, component }) + +const gaming = createRoute({ + getParentRoute: () => root, + path: '/gaming', + validateSearch: (s: Record): { demo?: boolean } => + s.demo === true || s.demo === '1' || s.demo === 1 ? { demo: true } : {}, + component: function GamingRoute() { + return + }, +}) + +const jobDetail = createRoute({ + getParentRoute: () => root, + path: '/jobs/$jobId', + component: function JobRoute() { + return + }, +}) + +const routeTree = root.addChildren([ + page('/', Dashboard), + page('/backlog', Backlog), + page('/picks', Picks), + gaming, + page('/library', Library), + page('/store', Store), + page('/jobs', Jobs), + jobDetail, + page('/system', System), + page('/setup', Setup), +]) + +export const router = createRouter({ routeTree, defaultPreload: 'intent', scrollRestoration: true }) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} diff --git a/web/src/screens/Backlog.test.tsx b/web/src/screens/Backlog.test.tsx new file mode 100644 index 0000000..425fbf8 --- /dev/null +++ b/web/src/screens/Backlog.test.tsx @@ -0,0 +1,63 @@ +import { screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeDataset, makeGame, makePaceVsHltb } from '../test/factories' +import { apiError, renderClient, stubApi } from '../test/harness' +import { Backlog } from './Backlog' + +const summary = { qualifying: 42, rush_total: 100, leisure_total: 250, worst_total: 400 } +const stats = (pace: ReturnType | null) => ({ default_summary: summary, pace_vs_hltb: pace }) +const show = (statsReply: unknown) => { + stubApi({ 'GET /api/stats': statsReply, 'GET /api/dataset': makeDataset([makeGame({ app_id: 1, name: 'Alpha' }), makeGame({ app_id: 2, name: 'Beta' })]) }) + renderClient() +} +const fact = (label: string) => screen.getByText(label, { selector: 'dt' }).nextElementSibling! + +describe('Backlog', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('shows the estimates for the default filters', async () => { + show(stats(null)) + expect(await screen.findByText('qualifying games')).toBeInTheDocument() + expect(screen.getByText('42')).toBeInTheDocument() + expect(screen.getByText('100 h')).toBeInTheDocument() + expect(screen.getByText('250 h')).toBeInTheDocument() + expect(screen.getByText('400 h')).toBeInTheDocument() + expect(screen.queryByText('Your pace')).toBeNull() + }) + + it('adds the player’s pace against HLTB once calibrated', async () => { + show(stats(makePaceVsHltb({ player_style: 'rush_to_leisure', ratio_vs_rush: 1.05, ratio_vs_leisure: 0.4, calibration_count: 12 }))) + expect(await screen.findByText('Your pace')).toBeInTheDocument() + expect(fact('Your pace')).toHaveTextContent('Between rush and leisure') + expect(fact('vs rush')).toHaveTextContent('1.05×') + expect(fact('vs leisure')).toHaveTextContent('0.40×') + expect(fact('Calibrated on')).toHaveTextContent('12 finished games') + }) + + it('dashes a ratio the server could not compute', async () => { + show(stats(makePaceVsHltb({ player_style: 'unknown', ratio_vs_rush: -1, ratio_vs_leisure: -1 }))) + expect(await screen.findByText('Not enough finished games yet')).toBeInTheDocument() + expect(fact('vs rush')).toHaveTextContent('—') + expect(fact('vs leisure')).toHaveTextContent('—') + }) + + it('shows loading while stats load', async () => { + show(() => new Promise(() => {})) + expect(await screen.findByText(/Loading stats/)).toBeInTheDocument() + }) + + it('explains a failed stats load', async () => { + show(apiError(500, 'op_failed', 'stats broke')) + expect(await screen.findByText('stats broke')).toBeInTheDocument() + }) + + it('embeds the interactive planner', async () => { + show(stats(null)) + expect(await screen.findByRole('heading', { name: 'Backlog Completion Planner' })).toBeInTheDocument() + const table = await screen.findByRole('table') + expect(within(table).getByText('Alpha')).toBeInTheDocument() + await userEvent.type(screen.getByPlaceholderText(/Search games/i), 'Beta') + expect(within(table).queryByText('Alpha')).toBeNull() + }) +}) diff --git a/web/src/screens/Backlog.tsx b/web/src/screens/Backlog.tsx new file mode 100644 index 0000000..def2f0e --- /dev/null +++ b/web/src/screens/Backlog.tsx @@ -0,0 +1,54 @@ +import { useQuery } from '@tanstack/react-query' +import { q } from '../api/queries' +import { PlannerView } from '../components/PlannerView' +import { fmtHours } from '../format' +import { ErrorNotice, Loading } from '../ui/Notice' +import { Card, Facts, PageHead } from '../ui/Page' + +const STYLE: Record = { + faster_than_rush: 'Faster than HLTB rush', + rush_to_leisure: 'Between rush and leisure', + slower_than_leisure: 'Slower than HLTB leisure', + unknown: 'Not enough finished games yet', +} + +/** `stats`: the CLI's completion-time estimate for the default filters. */ +function StatsCard() { + const { data, error } = useQuery(q.stats) + if (error) return + if (!data) return + const s = data.default_summary + const p = data.pace_vs_hltb + return ( + +
+
{s.qualifying}qualifying games
+
{fmtHours(s.rush_total)}rush
+
{fmtHours(s.leisure_total)}leisure
+
{fmtHours(s.worst_total)}worst case
+
+ {p && ( + + )} +
+ ) +} + +export function Backlog() { + return ( + <> + + +
+ +
+ + ) +} diff --git a/web/src/screens/Dashboard.test.tsx b/web/src/screens/Dashboard.test.tsx new file mode 100644 index 0000000..b5e0c99 --- /dev/null +++ b/web/src/screens/Dashboard.test.tsx @@ -0,0 +1,144 @@ +import { screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeBudget, makeBudgetToday } from '../test/factories' +import { makeJob, makeSpec, makeStatus } from '../test/fixtures' +import { apiError, renderRouted, stubApi } from '../test/harness' +import { Dashboard } from './Dashboard' + +const base = (over: Record = {}) => ({ + 'GET /api/status': makeStatus(), + 'GET /api/budget': makeBudget(), + 'GET /api/jobs': [], + 'GET /api/commands': [makeSpec('done'), makeSpec('install'), makeSpec('pick'), makeSpec('scan'), makeSpec('check')], + ...over, +}) +const show = (over: Record = {}) => { + stubApi(base(over)) + return renderRouted() +} +const fact = (label: string) => screen.getByText(label, { selector: 'dt' }).nextElementSibling! + +describe('Dashboard', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('offers the quick actions', async () => { + show() + expect(await screen.findByRole('button', { name: 'I got an achievement' })).toBeInTheDocument() + for (const name of ['Install assigned', 'Pick next', 'Scan']) expect(screen.getByRole('button', { name })).toBeInTheDocument() + }) + + describe('assignment', () => { + it('summarises a healthy state', async () => { + show() + expect(await screen.findByText('Hollow Knight')).toBeInTheDocument() + expect(fact('Installed')).toHaveTextContent('Yes') + expect(fact('Games finished')).toHaveTextContent('3') + expect(fact('Store')).toHaveTextContent('Blocked') + expect(fact('Installed games')).toHaveTextContent('4') + expect(fact('Total block')).toHaveTextContent('Off') + expect(fact('Manual picks')).toHaveTextContent('None') + }) + + it('warns about an uninstalled assignment, open store, total block and picks', async () => { + show({ + 'GET /api/status': makeStatus({ + current_game_name: null, + assigned_game_installed: false, + store_blocked: false, + total_block: { active: true, days_remaining: 5, until: null }, + manual_picks: [{ app_id: 1, name: 'Alpha', age_days: 2 }, { app_id: 2, name: 'Beta', age_days: 9 }], + }), + }) + expect(await screen.findByText('Nothing assigned')).toBeInTheDocument() + expect(fact('Installed')).toHaveTextContent('No — install it from Library') + expect(fact('Store')).toHaveTextContent('Unblocked') + expect(fact('Total block')).toHaveTextContent('5 days left') + expect(fact('Manual picks')).toHaveTextContent('Alpha (2 d), Beta (9 d)') + }) + + it('shows loading, then an error', async () => { + show({ 'GET /api/status': () => new Promise(() => {}) }) + expect(await screen.findByText(/Loading status/)).toBeInTheDocument() + }) + + it('explains a failed load', async () => { + show({ 'GET /api/status': apiError(500, 'op_failed', 'status broke') }) + expect(await screen.findByText('status broke')).toBeInTheDocument() + }) + }) + + describe('gaming today', () => { + it('shows time left and what is billing', async () => { + show() + expect(await screen.findByText(/left of/)).toBeInTheDocument() + expect(screen.getByText('Now: Hollow Knight')).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'Budget used' }).className).toContain('bar-accent') + expect(screen.getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/gaming') + }) + + it('says nothing is billing', async () => { + show({ 'GET /api/budget': makeBudget({ session: { ...makeBudget().session, billing_label: '' } }) }) + expect(await screen.findByText('Now: nothing billing')).toBeInTheDocument() + }) + + it('warns when the day is almost used, and reports a block', async () => { + show({ 'GET /api/budget': makeBudget({ today: makeBudgetToday({ fraction_used: 0.9 }) }) }) + expect(await screen.findByRole('progressbar', { name: 'Budget used' })).toHaveClass('bar-warning') + }) + + it('reports a blocked day', async () => { + show({ 'GET /api/budget': makeBudget({ today: makeBudgetToday({ blocked: true, fraction_used: 1 }) }) }) + expect(await screen.findByText('Blocked for today.')).toBeInTheDocument() + expect(screen.getByRole('progressbar', { name: 'Budget used' })).toHaveClass('bar-danger') + }) + + it('shows the budget error, or a default, when there is no today', async () => { + show({ 'GET /api/budget': makeBudget({ today: null, error: 'state file unreadable' }) }) + expect(await screen.findByText('state file unreadable')).toBeInTheDocument() + }) + + it('has a default message too', async () => { + show({ 'GET /api/budget': makeBudget({ today: null, error: null }) }) + expect(await screen.findByText('Budget state unavailable.')).toBeInTheDocument() + }) + + it('shows loading, then a request error', async () => { + show({ 'GET /api/budget': () => new Promise(() => {}) }) + expect(await screen.findByText(/Loading budget/)).toBeInTheDocument() + }) + + it('explains a failed load', async () => { + show({ 'GET /api/budget': apiError(500, 'op_failed', 'budget broke') }) + expect(await screen.findByText('API returned 500 Error')).toBeInTheDocument() + }) + }) + + describe('recent jobs', () => { + it('invites running one when there are none', async () => { + show() + expect(await screen.findByText(/No jobs yet/)).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'All jobs' })).toHaveAttribute('href', '/jobs') + }) + + it('lists five with summary, progress or nothing', async () => { + const jobs = [ + makeJob({ id: 'a', command: 'scan', state: 'succeeded', summary: 'Scanned 12' }), + makeJob({ id: 'b', command: 'check', progress: { step: 1, total: 2, label: 'Checking games' } }), + makeJob({ id: 'c', command: 'done' }), + makeJob({ id: 'd' }), + makeJob({ id: 'e' }), + makeJob({ id: 'f' }), + ] + show({ 'GET /api/jobs': jobs }) + expect(await screen.findByText('Scanned 12')).toBeInTheDocument() + expect(screen.getByText('Checking games')).toBeInTheDocument() + expect(document.querySelectorAll('.rows .row-link')).toHaveLength(5) + expect(document.querySelector('a[href="/jobs/a"]')).toHaveTextContent('Scan') + }) + + it('explains a failed load', async () => { + show({ 'GET /api/jobs': apiError(500, 'op_failed', 'jobs broke') }) + expect(await screen.findByText('jobs broke')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/screens/Dashboard.tsx b/web/src/screens/Dashboard.tsx new file mode 100644 index 0000000..2fb5701 --- /dev/null +++ b/web/src/screens/Dashboard.tsx @@ -0,0 +1,100 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import type { StatusPayload } from '../api/contract' +import { q } from '../api/queries' +import { CommandButton } from '../commands/CommandCard' +import { commandLabel } from '../commands/catalog' +import { fmtDuration } from '../format' +import { ProgressBar, StateBadge } from '../jobs/ProgressBar' +import { ago } from '../ui/format' +import { Empty, ErrorNotice, Loading } from '../ui/Notice' +import { Card, Facts, PageHead } from '../ui/Page' + +function StatusCard({ s }: { s: StatusPayload }) { + return ( + }> +

{s.current_game_name ?? 'Nothing assigned'}

+ No — install it from Library], + ['Games finished', s.finished_count], + ['Store', s.store_blocked ? 'Blocked' : Unblocked], + ['Installed games', s.installed_count], + [ + 'Total block', + s.total_block.active ? {s.total_block.days_remaining} days left : 'Off', + ], + [ + 'Manual picks', + s.manual_picks.length ? s.manual_picks.map((p) => `${p.name} (${p.age_days} d)`).join(', ') : 'None', + ], + ]} + /> +
+ ) +} + +function BudgetGlance() { + const { data, error } = useQuery(q.budget) + if (error) return + if (!data) return + const t = data.today + return ( + Details}> + {t ? ( + <> +

+ {fmtDuration(t.seconds_remaining)} left of {fmtDuration(t.budget_seconds)} +

+ 0.8 ? 'warning' : 'accent'} /> +

+ {t.blocked ? 'Blocked for today.' : `Now: ${data.session.billing_label || 'nothing billing'}`} +

+ + ) : ( +

{data.error ?? 'Budget state unavailable.'}

+ )} +
+ ) +} + +function RecentJobs() { + const { data, error } = useQuery(q.jobs) + return ( + All jobs}> + {error && } + {data?.length === 0 && No jobs yet. Run one from the actions or press Ctrl K.} +
    + {data?.slice(0, 5).map((j) => ( +
  • + + {commandLabel(j.command)} + {j.summary ?? j.progress?.label ?? ''} + {ago(j.created_at)} + + +
  • + ))} +
+
+ ) +} + +export function Dashboard() { + const { data, error } = useQuery(q.status) + return ( + <> + + + + + + +
+ {error ? : data ? : } + +
+ + + ) +} diff --git a/web/src/screens/Gaming.test.tsx b/web/src/screens/Gaming.test.tsx new file mode 100644 index 0000000..2ac283b --- /dev/null +++ b/web/src/screens/Gaming.test.tsx @@ -0,0 +1,34 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeBudget } from '../test/factories' +import { makeSpec } from '../test/fixtures' +import { renderRouted, stubApi } from '../test/harness' +import { Gaming } from './Gaming' + +const SPECS = [makeSpec('gaming-reset'), makeSpec('block-gaming'), makeSpec('gaming-unblock')] + +describe('Gaming', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('shows the production budget and the three gaming controls', async () => { + const mock = stubApi({ 'GET /api/commands': SPECS, 'GET /api/budget': makeBudget() }) + renderRouted() + expect(await screen.findByRole('heading', { name: 'Today' })).toBeInTheDocument() + for (const name of ['Reset today…', 'Block gaming…', 'Release mounts…']) { + expect(screen.getAllByText(name).length).toBeGreaterThan(0) + } + expect(mock.mock.calls.some(([p]) => p === '/api/budget')).toBe(true) + expect(screen.getByRole('link', { name: 'Show demo budget' })).toHaveAttribute('href', '/gaming?demo=true') + }) + + it('reads the demo run when asked, and links back', async () => { + const mock = stubApi({ 'GET /api/commands': SPECS, 'GET /api/budget?demo=1': makeBudget() }) + renderRouted() + await screen.findByRole('heading', { name: 'Today' }) + expect(mock.mock.calls.some(([p]) => p === '/api/budget?demo=1')).toBe(true) + const link = screen.getByRole('link', { name: 'Show production budget' }) + expect(link).toHaveAttribute('href', '/gaming?demo=false') + await userEvent.click(link) + }) +}) diff --git a/web/src/screens/Gaming.tsx b/web/src/screens/Gaming.tsx new file mode 100644 index 0000000..51b7ff7 --- /dev/null +++ b/web/src/screens/Gaming.tsx @@ -0,0 +1,27 @@ +import { Link } from '@tanstack/react-router' +import { CommandCard } from '../commands/CommandCard' +import { BudgetPanel } from '../components/BudgetPanel' +import { Actions, PageHead } from '../ui/Page' + +/** + * `gaming-status` (the live budget panel) plus every gaming control. `?demo=1` + * points the panel at the short-budget demo run, the only way to watch the + * cutoff engage without spending a real day. + */ +export function Gaming({ demo }: { demo: boolean }) { + return ( + <> + + + {demo ? 'Show production budget' : 'Show demo budget'} + + + + + + + + + + ) +} diff --git a/web/src/screens/Jobs.test.tsx b/web/src/screens/Jobs.test.tsx new file mode 100644 index 0000000..718044e --- /dev/null +++ b/web/src/screens/Jobs.test.tsx @@ -0,0 +1,71 @@ +import { 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, installEventSource, renderRouted, stubApi } from '../test/harness' +import { JobDetail, Jobs } from './Jobs' + +describe('Jobs', () => { + beforeEach(installEventSource) + afterEach(() => vi.unstubAllGlobals()) + + it('lists jobs with command, params, state, duration and summary', async () => { + stubApi({ + 'GET /api/jobs': [ + makeJob({ id: 'a', command: 'pick-manual', params: { app_id: 5, note: 'x' }, state: 'succeeded', started_at: '2026-10-10T10:00:00Z', ended_at: '2026-10-10T10:01:05Z', summary: 'Picked 5' }), + makeJob({ id: 'b', command: 'scan', progress: { step: 1, total: 2, label: 'Scanning' } }), + makeJob({ id: 'c', command: 'done' }), + ], + }) + renderRouted() + expect(await screen.findByRole('table')).toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Pick manual' })).toHaveAttribute('href', '/jobs/a') + expect(screen.getByText('app_id=5 note=x')).toBeInTheDocument() + expect(screen.getByText('1:05')).toBeInTheDocument() + expect(screen.getByText('Picked 5')).toBeInTheDocument() + expect(screen.getByText('Scanning')).toBeInTheDocument() + expect(screen.getAllByText('Running')).toHaveLength(2) + }) + + it('shows loading, then an invitation when there are no jobs', async () => { + let answer: (v: unknown) => void = () => {} + stubApi({ 'GET /api/jobs': () => new Promise((r) => (answer = r)) }) + renderRouted() + expect(await screen.findByText(/Loading jobs/)).toBeInTheDocument() + answer([]) + expect(await screen.findByText(/No jobs yet/)).toBeInTheDocument() + expect(screen.queryByRole('table')).toBeNull() + }) + + it('explains a failed load', async () => { + stubApi({ 'GET /api/jobs': apiError(500, 'op_failed', 'jobs broke') }) + renderRouted() + expect(await screen.findByText('jobs broke')).toBeInTheDocument() + expect(screen.queryByText(/Loading jobs/)).toBeNull() + }) +}) + +describe('JobDetail', () => { + beforeEach(installEventSource) + afterEach(() => vi.unstubAllGlobals()) + + it('shows the job with its live progress', async () => { + stubApi({ + 'GET /api/jobs/jd-1': makeJob({ id: 'jd-1', command: 'scan', created_at: '2026-10-10T10:00:00Z' }), + 'GET /api/commands': [makeSpec('scan')], + }) + renderRouted() + expect(await screen.findByRole('heading', { name: 'Scan', level: 1 })).toBeInTheDocument() + expect(screen.getByText(/created/)).toBeInTheDocument() + expect(screen.getByRole('region', { name: 'Job Scan' })).toBeInTheDocument() + await userEvent.click(screen.getByRole('link', { name: 'All jobs' })) + }) + + it('titles itself generically until the job loads, and shows an error', async () => { + stubApi({ 'GET /api/jobs/jd-2': apiError(404, 'not_found', 'no such job'), 'GET /api/commands': [] }) + renderRouted() + expect(await screen.findByRole('heading', { name: 'Job', level: 1 })).toBeInTheDocument() + expect(await screen.findByText('no such job')).toBeInTheDocument() + expect(screen.getByText('jd-2')).toBeInTheDocument() + }) +}) diff --git a/web/src/screens/Jobs.tsx b/web/src/screens/Jobs.tsx new file mode 100644 index 0000000..59a3a8d --- /dev/null +++ b/web/src/screens/Jobs.tsx @@ -0,0 +1,71 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { q } from '../api/queries' +import { commandLabel } from '../commands/catalog' +import { JobProgress } from '../jobs/JobProgress' +import { StateBadge } from '../jobs/ProgressBar' +import { dateTime, duration } from '../ui/format' +import { Empty, ErrorNotice, Loading } from '../ui/Notice' +import { Card, PageHead } from '../ui/Page' +import { useNow } from '../ui/useNow' + +export function Jobs() { + const { data, error } = useQuery(q.jobs) + const now = useNow(1000) + return ( + <> + + + {error && } + {!data && !error && } + {data?.length === 0 && No jobs yet. Press Ctrl K to run a command.} + {data && data.length > 0 && ( +
+ + + + + + + + + + + + {data.map((j) => ( + + + + + + + + ))} + +
CommandStateStartedTookSummary
+ {commandLabel(j.command)} + {Object.keys(j.params).length > 0 && ( + {Object.entries(j.params).map(([k, v]) => `${k}=${v}`).join(' ')} + )} + {dateTime(j.created_at)}{duration(j.started_at, j.ended_at, now)}{j.summary ?? j.progress?.label ?? ''}
+
+ )} +
+ + ) +} + +export function JobDetail({ jobId }: { jobId: string }) { + const { data, error } = useQuery(q.job(jobId)) + return ( + <> + Job {jobId}{data && <> · created {dateTime(data.created_at)}}}> + All jobs + + {error && } + + + + + ) +} diff --git a/web/src/screens/Library.test.tsx b/web/src/screens/Library.test.tsx new file mode 100644 index 0000000..eedb61a --- /dev/null +++ b/web/src/screens/Library.test.tsx @@ -0,0 +1,87 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeInstalled, makeLibGame, makeSpec } from '../test/fixtures' +import { apiError, renderRouted, stubApi } from '../test/harness' +import { Library } from './Library' + +const routes = (installed: unknown = { games: [] }) => ({ + 'GET /api/library': { games: [makeLibGame({ app_id: 1, name: 'Hollow Knight' })] }, + 'GET /api/installed': installed, + 'GET /api/commands': [makeSpec('install'), makeSpec('uninstall'), makeSpec('hide'), makeSpec('unhide')], +}) + +describe('Library', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('opens on the cover grid, with the install actions above', async () => { + stubApi(routes()) + renderRouted() + expect(await screen.findByText('1 of 1 games')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'All games' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Installed' })).toHaveAttribute('aria-pressed', 'false') + for (const label of ['Install assigned', 'Hide others', 'Unhide all']) { + expect(screen.getAllByText(label).length).toBeGreaterThan(0) + } + }) + + it('counts what an uninstall would remove', async () => { + stubApi(routes({ + games: [ + makeInstalled({ app_id: 1 }), + makeInstalled({ app_id: 2, assigned: true }), + makeInstalled({ app_id: 3, protected: true }), + makeInstalled({ app_id: 4 }), + ], + })) + renderRouted() + expect((await screen.findAllByText('Uninstall 2…')).length).toBeGreaterThan(0) + }) + + it('switches to the installed table and back', async () => { + stubApi(routes({ + games: [ + makeInstalled({ app_id: 1, name: 'Assigned One', assigned: true, size_bytes: 2 * 1024 ** 3 }), + makeInstalled({ app_id: 2, name: 'Proton', protected: true }), + makeInstalled({ app_id: 3, name: 'Spare', size_bytes: 1024 ** 2 }), + ], + })) + renderRouted() + await screen.findByText('1 of 1 games') + await userEvent.click(screen.getByRole('button', { name: 'Installed' })) + expect(await screen.findByRole('region', { name: 'Installed games' })).toBeInTheDocument() + expect(screen.getByText(/3 games · 3\.0 GB/)).toBeInTheDocument() // 2 GB + 1 GB + 1 MB + expect(screen.getByText('assigned')).toBeInTheDocument() + expect(screen.getByText('protected')).toBeInTheDocument() + expect(screen.getByText('uninstall candidate')).toBeInTheDocument() + expect(screen.queryByText('1 of 1 games')).toBeNull() + await userEvent.click(screen.getByRole('button', { name: 'All games' })) + expect(await screen.findByText('1 of 1 games')).toBeInTheDocument() + }) + + it('says when nothing is installed', async () => { + stubApi(routes()) + renderRouted() + await screen.findByText('1 of 1 games') + await userEvent.click(screen.getByRole('button', { name: 'Installed' })) + expect(await screen.findByText('No games installed.')).toBeInTheDocument() + expect(screen.queryByRole('table')).toBeNull() + }) + + it('shows loading, then a failure, for the installed table', async () => { + const table = routes(() => new Promise(() => {})) + stubApi(table) + renderRouted() + await screen.findByText('1 of 1 games') + await userEvent.click(screen.getByRole('button', { name: 'Installed' })) + expect(await screen.findByText(/Loading installed games/)).toBeInTheDocument() + }) + + it('explains a failed installed load', async () => { + stubApi(routes(apiError(500, 'op_failed', 'installed broke'))) + renderRouted() + await screen.findByText('1 of 1 games') + await userEvent.click(screen.getByRole('button', { name: 'Installed' })) + expect(await screen.findByText('installed broke')).toBeInTheDocument() + }) +}) diff --git a/web/src/screens/Library.tsx b/web/src/screens/Library.tsx new file mode 100644 index 0000000..afc8677 --- /dev/null +++ b/web/src/screens/Library.tsx @@ -0,0 +1,87 @@ +import { useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { q } from '../api/queries' +import { CommandCard } from '../commands/CommandCard' +import { GameBrowser } from '../library/GameBrowser' +import { bytes } from '../ui/format' +import { Empty, ErrorNotice, Loading } from '../ui/Notice' +import { Actions, Card, PageHead } from '../ui/Page' + +const VIEWS = [ + ['all', 'All games'], + ['installed', 'Installed'], +] as const + +/** Every owned game as a cover grid, or `installed` as a table; actions above. */ +export function Library() { + const [view, setView] = useState<'all' | 'installed'>('all') + return ( + <> + + +
+ {VIEWS.map(([key, label]) => ( + + ))} +
+ {view === 'all' ? : } + + ) +} + +function LibraryActions() { + const { data } = useQuery(q.installed) + const removable = (data?.games ?? []).filter((g) => !g.assigned && !g.protected) + return ( + + + + + + + ) +} + +/** `installed` as a table. */ +function InstalledTable() { + const { data, error } = useQuery(q.installed) + const games = data?.games ?? [] + const total = games.reduce((a, g) => a + g.size_bytes, 0) + return ( + {games.length} games · {bytes(total)}}> + {error && } + {!data && !error && } + {data && games.length === 0 && No games installed.} + {games.length > 0 && ( +
+ + + + + + + + + + + {games.map((g) => ( + + + + + + + ))} + +
GameApp idSizeRole
{g.name}{g.app_id}{bytes(g.size_bytes)} + {g.assigned && assigned} + {g.protected && protected} + {!g.assigned && !g.protected && uninstall candidate} +
+
+ )} +
+ ) +} diff --git a/web/src/screens/NotFound.tsx b/web/src/screens/NotFound.tsx new file mode 100644 index 0000000..72dce5d --- /dev/null +++ b/web/src/screens/NotFound.tsx @@ -0,0 +1,11 @@ +import { Link } from '@tanstack/react-router' +import { PageHead } from '../ui/Page' + +export function NotFound() { + return ( + <> + + Back to the dashboard + + ) +} diff --git a/web/src/screens/Picks.tsx b/web/src/screens/Picks.tsx new file mode 100644 index 0000000..57d458f --- /dev/null +++ b/web/src/screens/Picks.tsx @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query' +import { q } from '../api/queries' +import { CommandButton, CommandCard } from '../commands/CommandCard' +import { Empty, ErrorNotice, Loading } from '../ui/Notice' +import { Actions, Card, PageHead } from '../ui/Page' + +/** Everything that changes which game is assigned. */ +export function Picks() { + const { data, error } = useQuery(q.status) + return ( + <> + Assigned: {data.current_game_name} : 'Which game you are playing, and how to move on.'} + /> + + + + + + + + + {error && } + {!data && !error && } + {data && data.manual_picks.length === 0 && ( + No manual picks. "Lock in a game" adds one and locks most commands for 14 days. + )} +
    + {data?.manual_picks.map((p) => ( +
  • + + {p.name} · picked {p.age_days} d ago · {p.app_id} + + +
  • + ))} +
+ {data && data.manual_picks.length === 0 && ( +
+ +
+ )} +
+ + ) +} diff --git a/web/src/screens/Setup.test.tsx b/web/src/screens/Setup.test.tsx new file mode 100644 index 0000000..117abcf --- /dev/null +++ b/web/src/screens/Setup.test.tsx @@ -0,0 +1,110 @@ +import { 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 { apiError, bodiesOf, renderRouted, stubApi } from '../test/harness' +import { Setup } from './Setup' + +const KEY = '0123456789abcdef0123456789ABCDEF' +const ID = '76561198000000000' +const status = (over = {}) => ({ configured: true, has_api_key: true, steam_id: ID, ...over }) +const keyBox = () => screen.findByLabelText('Steam Web API key') +const idBox = () => screen.getByLabelText('SteamID64') + +describe('Setup', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('prefills the id, never the key, and says a key is stored', async () => { + stubApi({ 'GET /api/setup': status() }) + renderRouted() + const key = await keyBox() + expect(key).toHaveValue('') + expect(key).toHaveFocus() + await waitFor(() => expect(key).toHaveAttribute('placeholder', expect.stringContaining('A key is stored'))) + await waitFor(() => expect(idBox()).toHaveValue(ID)) + expect(screen.queryByText('Not configured yet')).toBeNull() + }) + + it('warns on a first run, with a hint for the empty form', async () => { + stubApi({ 'GET /api/setup': status({ configured: false, has_api_key: false, steam_id: null }) }) + renderRouted() + expect(await screen.findByText('Not configured yet')).toBeInTheDocument() + expect(await keyBox()).toHaveAttribute('placeholder', '32 hex characters') + expect(idBox()).toHaveValue('') + }) + + it('shows loading, then a failure', async () => { + stubApi({ 'GET /api/setup': () => new Promise(() => {}) }) + renderRouted() + expect(await screen.findByText(/Loading setup/)).toBeInTheDocument() + }) + + it('explains a failed load', async () => { + stubApi({ 'GET /api/setup': apiError(500, 'op_failed', 'setup broke') }) + renderRouted() + expect(await screen.findByText('setup broke')).toBeInTheDocument() + expect(screen.queryByText(/Loading setup/)).toBeNull() + }) + + it('rejects malformed values, showing both reasons', async () => { + const mock = stubApi({ 'GET /api/setup': status({ steam_id: null }) }) + renderRouted() + await userEvent.type(await keyBox(), 'nothex') + await userEvent.type(idBox(), '123') + await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) + expect(screen.getByText('The key is 32 hexadecimal characters.')).toBeInTheDocument() + expect(screen.getByText('A SteamID64 is 17 digits and starts with 7656.')).toBeInTheDocument() + expect(screen.getByLabelText('Steam Web API key')).toHaveAttribute('aria-invalid', 'true') + expect(bodiesOf(mock, 'POST /api/setup')).toEqual([]) + }) + + it('accepts a fine key with a bad id, and a fine id with a bad key', async () => { + stubApi({ 'GET /api/setup': status() }) + renderRouted() + await userEvent.type(await keyBox(), KEY) + await userEvent.clear(idBox()) + await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) + expect(screen.queryByText('The key is 32 hexadecimal characters.')).toBeNull() + expect(screen.getByText(/17 digits/)).toBeInTheDocument() + }) + + it('saves trimmed values, clears the key and reports success', async () => { + let stored = status({ has_api_key: false, steam_id: null, configured: false }) + const mock = stubApi({ + 'GET /api/setup': () => stored, + 'POST /api/setup': () => (stored = status()), + }) + renderRouted() + await userEvent.type(await keyBox(), ` ${KEY} `) + await userEvent.type(idBox(), ` ${ID} `) + await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) + expect(await screen.findByText('Saved')).toBeInTheDocument() + expect(bodiesOf(mock, 'POST /api/setup')).toEqual([{ steam_api_key: KEY, steam_id: ID }]) + expect(screen.getByLabelText('Steam Web API key')).toHaveValue('') + expect(screen.getByRole('link', { name: 'Go to the dashboard' })).toHaveAttribute('href', '/') + expect(queryClient.getQueryData(['setup'])).toEqual(status()) + }) + + it('keeps the stored id when only the key changes', async () => { + const mock = stubApi({ 'GET /api/setup': status(), 'POST /api/setup': status() }) + renderRouted() + await userEvent.type(await keyBox(), KEY) + await waitFor(() => expect(idBox()).toHaveValue(ID)) + await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) + await screen.findByText('Saved') + expect(bodiesOf(mock, 'POST /api/setup')).toEqual([{ steam_api_key: KEY, steam_id: ID }]) + }) + + it('shows Steam’s refusal, and Checking… meanwhile', async () => { + let answer: (v: unknown) => void = () => {} + stubApi({ 'GET /api/setup': status(), 'POST /api/setup': () => new Promise((r) => (answer = r)) }) + renderRouted() + await userEvent.type(await keyBox(), KEY) + await waitFor(() => expect(idBox()).toHaveValue(ID)) + await userEvent.click(screen.getByRole('button', { name: 'Save and verify' })) + expect(await screen.findByRole('button', { name: 'Checking with Steam…' })).toBeDisabled() + answer(apiError(400, 'invalid_params', 'Steam rejected the key')) + expect(await screen.findByText('Steam rejected the key')).toBeInTheDocument() + expect(screen.queryByText('Saved')).toBeNull() + }) +}) diff --git a/web/src/screens/Setup.tsx b/web/src/screens/Setup.tsx new file mode 100644 index 0000000..06b729a --- /dev/null +++ b/web/src/screens/Setup.tsx @@ -0,0 +1,101 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { useState } from 'react' +import { api } from '../api/client' +import { q, queryClient } from '../api/queries' +import { ErrorNotice, Loading, Notice } from '../ui/Notice' +import { Card, PageHead } from '../ui/Page' + +const KEY_RE = /^[0-9A-Fa-f]{32}$/ +const ID_RE = /^7656\d{13}$/ + +/** + * `setup`: Steam API key + SteamID64. The key is write-only — the server + * never returns it, so the field always starts empty and only says whether + * one is stored. + */ +export function Setup() { + const { data, error } = useQuery(q.setup) + const [key, setKey] = useState('') + const [steamId, setSteamId] = useState(null) + const [touched, setTouched] = useState(false) + const id = steamId ?? data?.steam_id ?? '' + const save = useMutation({ + mutationFn: () => api.saveSetup({ steam_api_key: key.trim(), steam_id: id.trim() }), + onSuccess: (s) => { + queryClient.setQueryData(q.setup.queryKey, s) + setKey('') + void queryClient.invalidateQueries() + }, + }) + const keyErr = KEY_RE.test(key.trim()) ? null : 'The key is 32 hexadecimal characters.' + const idErr = ID_RE.test(id.trim()) ? null : 'A SteamID64 is 17 digits and starts with 7656.' + + return ( + <> + + {error && } + {!data && !error && } + {data && !data.configured && ( + Every other screen needs these two values first. + )} + {save.isSuccess && ( + + Steam accepted the key. Go to the dashboard. + + )} + +
{ + e.preventDefault() + setTouched(true) + if (!keyErr && !idErr) save.mutate() + }} + > +
+ + setKey(e.target.value)} + /> +

+ {touched && keyErr ? keyErr : <>Get one at steamcommunity.com/dev/apikey. It is never shown again.} +

+
+
+ + setSteamId(e.target.value)} + /> +

+ {touched && idErr ? idErr : 'Your profile URL or steamid.io shows it.'} +

+
+ {save.error && } +
+ +
+ +
+ + ) +} diff --git a/web/src/screens/Small.test.tsx b/web/src/screens/Small.test.tsx new file mode 100644 index 0000000..f26d722 --- /dev/null +++ b/web/src/screens/Small.test.tsx @@ -0,0 +1,90 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeSpec, makeStatus } from '../test/fixtures' +import { apiError, renderRouted, stubApi } from '../test/harness' +import { NotFound } from './NotFound' +import { Picks } from './Picks' +import { Store } from './Store' + +const SPECS = ['check', 'done', 'pick', 'scan', 'pick-manual', 'abandon-pick', 'unblock', 'buy-dlc', 'add-exception'].map((n) => + makeSpec(n as 'check'), +) + +describe('NotFound', () => { + it('points home', async () => { + const { router } = renderRouted() + const link = await screen.findByRole('link', { name: 'Back to the dashboard' }) + await userEvent.click(link) + expect(router.state.location.pathname).toBe('/') + expect(screen.getByRole('heading', { name: 'Not found' })).toBeInTheDocument() + }) +}) + +describe('Picks', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('lists manual picks, each with its abandon button', async () => { + stubApi({ + 'GET /api/commands': SPECS, + 'GET /api/status': makeStatus({ manual_picks: [{ app_id: 5, name: 'Five', age_days: 3 }, { app_id: 6, name: 'Six', age_days: 0 }] }), + }) + renderRouted() + expect(await screen.findByText('Five')).toBeInTheDocument() + expect(screen.getByText(/picked 3 d ago · 5/)).toBeInTheDocument() + expect(screen.getAllByRole('button', { name: 'Abandon…' })).toHaveLength(2) + expect(screen.queryByText(/No manual picks/)).toBeNull() + expect(screen.getByText('Hollow Knight', { selector: 'strong' })).toBeInTheDocument() + }) + + it('invites locking in a game when there are none', async () => { + stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) }) + renderRouted() + expect(await screen.findByText(/No manual picks/)).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Abandon a pick by app id…' })).toBeInTheDocument() + expect(screen.getByText('Which game you are playing, and how to move on.')).toBeInTheDocument() + for (const label of ['Check', 'Done', 'Pick…', 'Scan', 'Lock in a game…']) { + expect(screen.getAllByText(label).length).toBeGreaterThan(0) + } + }) + + it('shows loading, then a failure', async () => { + stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': () => new Promise(() => {}) }) + renderRouted() + expect(await screen.findByText(/Loading status/)).toBeInTheDocument() + }) + + it('explains a failed load', async () => { + stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': apiError(500, 'op_failed', 'status broke') }) + renderRouted() + expect(await screen.findByText('status broke')).toBeInTheDocument() + }) +}) + +describe('Store', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('says the store is blocked', async () => { + stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ store_blocked: true }) }) + renderRouted() + expect(await screen.findByText('Store blocked')).toBeInTheDocument() + expect(screen.getByText(/capped at 30 minutes/)).toBeInTheDocument() + for (const label of ['Unblock…', 'Buy DLC…', 'Request exception…']) { + expect(screen.getAllByText(label).length).toBeGreaterThan(0) + } + }) + + it('warns when it is open', async () => { + stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ store_blocked: false }) }) + renderRouted() + expect(await screen.findByText('Store unblocked')).toBeInTheDocument() + expect(screen.getByText(/re-blocks automatically/)).toBeInTheDocument() + }) + + it('shows no notice until the status is known', async () => { + stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': () => new Promise(() => {}) }) + renderRouted() + await screen.findByRole('heading', { name: 'Store' }) + expect(screen.queryByRole('status')).toBeNull() + }) +}) diff --git a/web/src/screens/Store.tsx b/web/src/screens/Store.tsx new file mode 100644 index 0000000..6fb4b92 --- /dev/null +++ b/web/src/screens/Store.tsx @@ -0,0 +1,26 @@ +import { useQuery } from '@tanstack/react-query' +import { q } from '../api/queries' +import { CommandCard } from '../commands/CommandCard' +import { Notice } from '../ui/Notice' +import { Actions, PageHead } from '../ui/Page' + +export function Store() { + const { data } = useQuery(q.status) + return ( + <> + + {data && ( + + {data.store_blocked + ? 'Unblocking takes a typed phrase and is capped at 30 minutes.' + : 'The store is open right now; it re-blocks automatically.'} + + )} + + + + + + + ) +} diff --git a/web/src/screens/System.test.tsx b/web/src/screens/System.test.tsx new file mode 100644 index 0000000..fb18d73 --- /dev/null +++ b/web/src/screens/System.test.tsx @@ -0,0 +1,51 @@ +import { 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 { System } from './System' + +const ROUTES = { + 'GET /api/setup': { configured: true, has_api_key: true, steam_id: '7656119800000000' }, + '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/backups': [], + 'GET /api/commands': [makeSpec('reset'), makeSpec('enforce')], +} + +describe('System', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('composes the server, setup, daemon and backup cards', async () => { + stubApi(ROUTES) + renderRouted() + for (const name of ['Web server', 'Setup', 'Enforcer daemon', 'State backups']) { + expect(await screen.findByRole('region', { name })).toBeInTheDocument() + } + expect(screen.getByText(/no way to stop the enforcer/)).toBeInTheDocument() + }) + + it('summarises setup', async () => { + stubApi(ROUTES) + renderRouted() + expect(await screen.findByText('Stored (never shown)')).toBeInTheDocument() + expect(screen.getByText('Yes')).toBeInTheDocument() + expect(screen.getByText('7656119800000000')).toBeInTheDocument() + await userEvent.click(screen.getByRole('link', { name: 'Open setup' })) + }) + + it('reports an unconfigured setup', async () => { + stubApi({ ...ROUTES, 'GET /api/setup': { configured: false, has_api_key: false, steam_id: null } }) + renderRouted() + expect(await screen.findByText('Missing')).toBeInTheDocument() + expect(screen.getByText('No')).toBeInTheDocument() + }) + + it('shows placeholders until setup loads', async () => { + stubApi({ ...ROUTES, 'GET /api/setup': () => new Promise(() => {}) }) + renderRouted() + const setup = await screen.findByRole('region', { name: 'Setup' }) + expect(setup.textContent?.match(/…/g)).toHaveLength(2) + expect(setup).toHaveTextContent('—') + }) +}) diff --git a/web/src/screens/System.tsx b/web/src/screens/System.tsx new file mode 100644 index 0000000..b47444b --- /dev/null +++ b/web/src/screens/System.tsx @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import { q } from '../api/queries' +import { Card, Facts, PageHead } from '../ui/Page' +import { BackupsCard } from './system/BackupsCard' +import { DaemonCard } from './system/DaemonCard' +import { ServerCard } from './system/ServerCard' + +function SetupCard() { + const { data } = useQuery(q.setup) + return ( + Open setup}> + {data?.steam_id ?? '—'}], + ]} + /> + + ) +} + +export function System() { + return ( + <> + +
+ + +
+ + + + ) +} diff --git a/web/src/screens/system/BackupsCard.test.tsx b/web/src/screens/system/BackupsCard.test.tsx new file mode 100644 index 0000000..e651b6e --- /dev/null +++ b/web/src/screens/system/BackupsCard.test.tsx @@ -0,0 +1,99 @@ +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 { makeJob, makeSpec } from '../../test/fixtures' +import { apiError, bodiesOf, installEventSource, renderRouted, stubApi } from '../../test/harness' +import { BackupsCard } from './BackupsCard' + +const BACKUP = { id: '20261010-100000', created_at: '2026-10-10T10:00:00Z', reason: 'before reset', size_bytes: 2048 } +const PHRASE = 'restore backup 20261010-100000' +const RESTORE = 'POST /api/backups/20261010-100000/restore' + +const show = (extra: Record = {}) => { + const mock = stubApi({ + 'GET /api/backups': [BACKUP], + 'GET /api/commands': [makeSpec('reset')], + 'GET /api/jobs': [], + 'GET /api/jobs/jr': makeJob({ id: 'jr', command: 'restore-backup' }), + ...extra, + }) + renderRouted() + return mock +} +const openDialog = async () => { + await userEvent.click(await screen.findByRole('button', { name: 'Restore…' })) + return screen.findByRole('dialog', { name: `Restore backup ${BACKUP.id}` }) +} + +describe('BackupsCard', () => { + beforeEach(installEventSource) + afterEach(() => vi.unstubAllGlobals()) + + it('lists backups with reason and size, next to the reset action', async () => { + show() + expect(await screen.findByText(BACKUP.id)).toBeInTheDocument() + expect(screen.getByText(/before reset · 2.0 KB/)).toBeInTheDocument() + expect(screen.getAllByText('Reset all state…').length).toBeGreaterThan(0) + }) + + it('says when there are none', async () => { + show({ 'GET /api/backups': [] }) + expect(await screen.findByText('No backups yet.')).toBeInTheDocument() + }) + + it('shows loading, then a failure', async () => { + show({ 'GET /api/backups': () => new Promise(() => {}) }) + expect(await screen.findByText(/Loading backups/)).toBeInTheDocument() + }) + + it('explains a failed load', async () => { + show({ 'GET /api/backups': apiError(500, 'op_failed', 'backups broke') }) + expect(await screen.findByText('backups broke')).toBeInTheDocument() + }) + + describe('restore dialog', () => { + it('asks for the exact phrase, and restores only when it matches', async () => { + const mock = show({ [RESTORE]: makeJob({ id: 'jr', command: 'restore-backup' }) }) + const dialog = await openDialog() + expect(dialog).toHaveTextContent('Replaces the current state with the backup from') + const restore = screen.getByRole('button', { name: 'Restore' }) + expect(restore).toBeDisabled() + await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), `${PHRASE}{Enter}`) + await waitFor(() => expect(bodiesOf(mock, RESTORE)).toEqual([{ confirm_phrase: PHRASE }])) + expect(await screen.findByRole('region', { name: /^Job/ })).toBeInTheDocument() + }) + + it('does not restore on Enter with a wrong phrase', async () => { + const mock = show() + await openDialog() + await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), 'nope') + // The button is disabled, so only a forced submit can reach the guard. + fireEvent.submit(screen.getByRole('button', { name: 'Restore' }).closest('form')!) + expect(bodiesOf(mock, RESTORE)).toEqual([]) + }) + + it('closes from the finished view, and from Cancel', async () => { + show({ [RESTORE]: makeJob({ id: 'jr', command: 'restore-backup' }) }) + await openDialog() + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + await openDialog() + await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), PHRASE) + await userEvent.click(screen.getByRole('button', { name: 'Restore' })) + await screen.findByRole('region', { name: /^Job/ }) + await userEvent.click(screen.getAllByRole('button', { name: 'Close' }).at(-1)!) // the footer one, not the header × + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('shows a refused restore and Restoring… while it runs', async () => { + let answer: (v: unknown) => void = () => {} + show({ [RESTORE]: () => new Promise((r) => (answer = r)) }) + await openDialog() + await userEvent.type(screen.getByLabelText('Type the phrase to confirm'), PHRASE) + await userEvent.click(screen.getByRole('button', { name: 'Restore' })) + expect(await screen.findByRole('button', { name: 'Restoring…' })).toBeDisabled() + await act(async () => answer(apiError(409, 'busy', 'a job is running'))) + expect(await screen.findByText('a job is running')).toBeInTheDocument() + }) + }) +}) diff --git a/web/src/screens/system/BackupsCard.tsx b/web/src/screens/system/BackupsCard.tsx new file mode 100644 index 0000000..ff4453a --- /dev/null +++ b/web/src/screens/system/BackupsCard.tsx @@ -0,0 +1,83 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { useState } from 'react' +import { api } from '../../api/client' +import type { StateBackup } from '../../api/contract' +import { q, queryClient } from '../../api/queries' +import { phraseMatches } from '../../commands/catalog' +import { CommandButton } from '../../commands/CommandCard' +import { PhraseField } from '../../commands/PhraseField' +import { JobProgress } from '../../jobs/JobProgress' +import { bytes, dateTime } from '../../ui/format' +import { Dialog } from '../../ui/Dialog' +import { Empty, ErrorNotice, Loading } from '../../ui/Notice' +import { Card } from '../../ui/Page' + +// Not in any CommandSpec (restore is not a CLI command), so the UI has to +// know it. Mirrors FRICTION["restore-backup"] in _friction.py. +const RESTORE_PHRASE = (id: string) => `restore backup ${id}` + +function RestoreDialog({ backup, onClose }: { backup: StateBackup; onClose: () => void }) { + const [typed, setTyped] = useState('') + const phrase = RESTORE_PHRASE(backup.id) + const restore = useMutation({ + mutationFn: () => api.restoreBackup(backup.id, typed), + onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['jobs'] }), + }) + return ( + + {restore.data ? ( +
+ +
+ +
+
+ ) : ( +
{ + e.preventDefault() + if (phraseMatches(phrase, typed)) restore.mutate() + }} + > +

+ Replaces the current state with the backup from {dateTime(backup.created_at)} ({backup.reason}). +

+ + {restore.error && } +
+ + +
+ + )} +
+ ) +} + +export function BackupsCard() { + const { data, error } = useQuery(q.backups) + const [restoring, setRestoring] = useState(null) + return ( + }> +

Reset takes a timestamped backup first, so it can be undone here.

+ {error && } + {!data && !error && } + {data?.length === 0 && No backups yet.} +
    + {data?.map((b) => ( +
  • + {b.id} + {dateTime(b.created_at)} · {b.reason} · {bytes(b.size_bytes)} + +
  • + ))} +
+ {restoring && setRestoring(null)} />} +
+ ) +} diff --git a/web/src/screens/system/DaemonCard.test.tsx b/web/src/screens/system/DaemonCard.test.tsx new file mode 100644 index 0000000..329db4a --- /dev/null +++ b/web/src/screens/system/DaemonCard.test.tsx @@ -0,0 +1,89 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RunnerContext } from '../../commands/runnerContext' +import { makeSpec } from '../../test/fixtures' +import { apiError, renderRouted, stubApi } from '../../test/harness' +import { DaemonCard } from './DaemonCard' + +const daemon = (over = {}) => ({ + state: 'running', + started_at: '2026-10-10T10:00:00Z', + pid: 4242, + journal_tail: ['line one', 'line two'], + restart_available_at: null, + ...over, +}) +const run = vi.fn() +const show = (reply: unknown) => { + stubApi({ 'GET /api/daemon': reply, 'GET /api/commands': [makeSpec('enforce')] }) + return renderRouted( + {} }}> + + , + ) +} + +describe('DaemonCard', () => { + afterEach(() => { + run.mockClear() + vi.unstubAllGlobals() + }) + + it('shows state, pid, uptime and the journal', async () => { + show(daemon()) + expect(await screen.findByText('4242')).toBeInTheDocument() + expect(screen.getByText('running')).toBeInTheDocument() + expect(screen.getByText(/ago/)).toBeInTheDocument() + expect(screen.getByLabelText('Daemon journal (live)')).toHaveTextContent(/line one\s+line two/) + expect(screen.queryByText('Daemon unreachable')).toBeNull() + }) + + it('restarts through the runner when allowed', async () => { + show(daemon()) + await userEvent.click(await screen.findByRole('button', { name: 'Restart daemon…' })) + expect(run).toHaveBeenCalledWith('enforce', { mode: 'restart' }) + }) + + it('refuses a restart while rate-limited, showing the wait', async () => { + show(daemon({ restart_available_at: new Date(Date.now() + 125_000).toISOString() })) + const button = await screen.findByRole('button', { name: /Restart available in [12]:\d\d/ }) + expect(button).toHaveAttribute('aria-disabled', 'true') + await userEvent.click(button) + expect(run).not.toHaveBeenCalled() + }) + + it('treats a lapsed rate limit as available', async () => { + show(daemon({ restart_available_at: new Date(Date.now() - 60_000).toISOString() })) + await userEvent.click(await screen.findByRole('button', { name: 'Restart daemon…' })) + expect(run).toHaveBeenCalled() + }) + + it('cannot restart a daemon that is not running, and says it is unreachable', async () => { + show(daemon({ state: 'unreachable', pid: null, started_at: null, journal_tail: [] })) + expect(await screen.findByText('Daemon unreachable')).toBeInTheDocument() + expect(screen.getByText('systemctl status steam-backlog-enforcer')).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Restart daemon…' }) + expect(button).toHaveAttribute('aria-disabled', 'true') + await userEvent.click(button) + expect(run).not.toHaveBeenCalled() + expect(screen.getByText('No journal lines.')).toBeInTheDocument() + expect(screen.getAllByText('—')).toHaveLength(2) + }) + + it('offers the demo run', async () => { + show(daemon()) + await userEvent.click(await screen.findByRole('button', { name: 'Demo run (enforce --demo)…' })) + expect(run).toHaveBeenCalledWith('enforce', { mode: 'demo' }) + }) + + it('shows loading, then a failure', async () => { + show(() => new Promise(() => {})) + expect(await screen.findByText(/Loading daemon status/)).toBeInTheDocument() + }) + + it('explains a failed load', async () => { + show(apiError(500, 'op_failed', 'daemon broke')) + expect(await screen.findByText('daemon broke')).toBeInTheDocument() + }) +}) diff --git a/web/src/screens/system/DaemonCard.tsx b/web/src/screens/system/DaemonCard.tsx new file mode 100644 index 0000000..267f56f --- /dev/null +++ b/web/src/screens/system/DaemonCard.tsx @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useRef } from 'react' +import { q } from '../../api/queries' +import { CommandButton } from '../../commands/CommandCard' +import { useRunner } from '../../commands/runnerContext' +import { ago, clock } from '../../ui/format' +import { CommandLine, ErrorNotice, Loading, Notice } from '../../ui/Notice' +import { Card, Facts } from '../../ui/Page' +import { useNow } from '../../ui/useNow' + +/** + * The root daemon: state, live journal tail, rate-limited restart and the + * 60 s demo run. Deliberately no stop/disable — the contract has none. + */ +export function DaemonCard() { + const { data, error } = useQuery(q.daemon) + const { run } = useRunner() + const now = useNow(1000) + const logRef = useRef(null) + const lines = data?.journal_tail.length ?? 0 + useEffect(() => { + const el = logRef.current + if (el) el.scrollTop = el.scrollHeight + }, [lines]) + + if (error) return + if (!data) return + + const waitMs = data.restart_available_at ? Date.parse(data.restart_available_at) - now : 0 + const limited = waitMs > 0 + return ( + + {data.state === 'unreachable' && ( + + Privileged actions (store unblock, gaming reset/block/unblock, restart) are unavailable. Check it from a terminal: + + + )} + +
+ + +
+

Journal

+
+        {data.journal_tail.length ? data.journal_tail.join('\n') : 'No journal lines.'}
+      
+
+ ) +} diff --git a/web/src/screens/system/ServerCard.test.tsx b/web/src/screens/system/ServerCard.test.tsx new file mode 100644 index 0000000..f18378e --- /dev/null +++ b/web/src/screens/system/ServerCard.test.tsx @@ -0,0 +1,52 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiError, renderClient, stubApi } from '../../test/harness' +import { ServerCard } from './ServerCard' + +const server = (over = {}) => ({ stale: false, started_at: '2026-10-10T10:00:00Z', version: '9.9.9', ...over }) + +describe('ServerCard', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('shows version, start time and that the code is current', async () => { + stubApi({ 'GET /api/server': server() }) + renderClient() + expect(await screen.findByText('9.9.9')).toBeInTheDocument() + expect(screen.getByText('current')).toBeInTheDocument() + expect(screen.queryByText('Stale')).toBeNull() + }) + + it('flags stale code', async () => { + stubApi({ 'GET /api/server': server({ stale: true }) }) + renderClient() + expect(await screen.findByText('Stale')).toBeInTheDocument() + expect(screen.getByText('stale')).toBeInTheDocument() + }) + + it('shows loading, then a failure', async () => { + stubApi({ 'GET /api/server': () => new Promise(() => {}) }) + renderClient() + expect(await screen.findByText(/Loading server health/)).toBeInTheDocument() + }) + + it('explains a failed load', async () => { + stubApi({ 'GET /api/server': apiError(500, 'op_failed', 'server broke') }) + renderClient() + expect(await screen.findByText('server broke')).toBeInTheDocument() + }) + + it('restarts, then waits for the new process', async () => { + stubApi({ 'GET /api/server': server(), 'POST /api/server/restart': undefined }) + renderClient() + await userEvent.click(await screen.findByRole('button', { name: 'Restart server' })) + expect(await screen.findByRole('button', { name: /Restarting… the page reloads/ })).toBeDisabled() + }) + + it('shows a refused restart', async () => { + stubApi({ 'GET /api/server': server(), 'POST /api/server/restart': apiError(429, 'rate_limited', 'too often') }) + renderClient() + await userEvent.click(await screen.findByRole('button', { name: 'Restart server' })) + expect(await screen.findByText('too often')).toBeInTheDocument() + }) +}) diff --git a/web/src/screens/system/ServerCard.tsx b/web/src/screens/system/ServerCard.tsx new file mode 100644 index 0000000..2b342ef --- /dev/null +++ b/web/src/screens/system/ServerCard.tsx @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query' +import { q } from '../../api/queries' +import { useServerRestart } from '../../shell/useServerRestart' +import { ago, dateTime } from '../../ui/format' +import { ErrorNotice, Loading, Notice } from '../../ui/Notice' +import { Card, Facts } from '../../ui/Page' + +/** `serve`: the unprivileged web server's health and restart. */ +export function ServerCard() { + const { data, error } = useQuery(q.server) + const { restart, restarting } = useServerRestart() + return ( + + {error && } + {!data && !error && } + {data && ( + <> + {data.stale && The code on disk is newer than this process.} + {data.version}], + ['Started', `${dateTime(data.started_at)} (${ago(data.started_at)})`], + ['Code', data.stale ? 'stale' : 'current'], + ]} + /> + + )} + {restart.error && } +
+ +
+
+ ) +} diff --git a/web/src/shell/AppShell.test.tsx b/web/src/shell/AppShell.test.tsx new file mode 100644 index 0000000..86ef04e --- /dev/null +++ b/web/src/shell/AppShell.test.tsx @@ -0,0 +1,80 @@ +import { act, screen, waitFor } 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 { AppShell } from './AppShell' +import { NAV } from './nav' + +const ROUTES = { + 'GET /api/setup': { configured: true, has_api_key: true, steam_id: '7656119800000000' }, + 'GET /api/server': { stale: false, started_at: 't', version: '1' }, + 'GET /api/daemon': { state: 'running', started_at: null, pid: 1, journal_tail: [], restart_available_at: null }, + 'GET /api/jobs': [], + 'GET /api/commands': [makeSpec('scan')], +} + +describe('AppShell', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('has a skip link, the sections, the top bar and a main landmark', async () => { + stubApi(ROUTES) + renderRouted() + expect(await screen.findByRole('link', { name: 'Skip to content' })).toHaveAttribute('href', '#main') + const nav = screen.getByRole('navigation', { name: 'Sections' }) + expect(nav.querySelectorAll('a')).toHaveLength(NAV.length) + expect(screen.getByRole('main')).toHaveAttribute('id', 'main') + expect(screen.getByRole('button', { name: /Commands/ })).toHaveAttribute('aria-keyshortcuts', 'Control+K') + expect(await screen.findByText('Server ok')).toBeInTheDocument() + }) + + it('marks the current section', async () => { + stubApi(ROUTES) + renderRouted(, '/gaming') + const link = await screen.findByRole('link', { name: /Gaming/ }) + expect(link).toHaveAttribute('aria-current', 'page') + expect(screen.getByRole('link', { name: /Dashboard/ })).not.toHaveAttribute('aria-current') + }) + + it('opens the palette from the top bar', async () => { + stubApi(ROUTES) + renderRouted() + await userEvent.click(await screen.findByRole('button', { name: /Commands/ })) + expect(await screen.findByRole('dialog', { name: 'Command palette' })).toBeInTheDocument() + }) + + it('sends an unconfigured install to Setup, and lets Setup itself render', async () => { + stubApi({ ...ROUTES, 'GET /api/setup': { configured: false, has_api_key: false, steam_id: null } }) + const { router } = renderRouted(, '/library') + await waitFor(() => expect(router.state.location.pathname).toBe('/setup')) + expect(await screen.findByRole('navigation', { name: 'Sections' })).toBeInTheDocument() + }) + + describe('Alt+digit', () => { + const go = async (keys: string, at = '/') => { + stubApi(ROUTES) + const view = renderRouted(, at) + await screen.findByRole('navigation', { name: 'Sections' }) + await userEvent.keyboard(keys) + return view.router + } + + it('jumps to the numbered section', async () => { + const router = await go('{Alt>}3{/Alt}') + await waitFor(() => expect(router.state.location.pathname).toBe('/picks')) + }) + + it('ignores a digit without Alt, with Ctrl or Meta, and unknown digits', async () => { + const router = await go('3{Alt>}9{/Alt}{Control>}{Alt>}2{/Alt}{/Control}{Meta>}{Alt>}2{/Alt}{/Meta}') + expect(router.state.location.pathname).toBe('/') + }) + + it('does nothing while a dialog is open', async () => { + const router = await go('{Control>}k{/Control}') + await screen.findByRole('dialog', { name: 'Command palette' }) + await userEvent.keyboard('{Alt>}2{/Alt}') + await act(async () => {}) + expect(router.state.location.pathname).toBe('/') + }) + }) +}) diff --git a/web/src/shell/AppShell.tsx b/web/src/shell/AppShell.tsx new file mode 100644 index 0000000..21ae485 --- /dev/null +++ b/web/src/shell/AppShell.tsx @@ -0,0 +1,85 @@ +import { useQuery } from '@tanstack/react-query' +import { Link, Navigate, Outlet, useLocation, useNavigate } from '@tanstack/react-router' +import { useEffect } from 'react' +import { q } from '../api/queries' +import { RunnerProvider } from '../commands/RunnerProvider' +import { useRunner } from '../commands/runnerContext' +import { HealthBadges, StaleBanner } from './Health' +import { JobIndicator } from './JobIndicator' +import { NAV } from './nav' + +function PaletteButton() { + const { openPalette } = useRunner() + return ( + + ) +} + +/** Alt+1…8 jumps between sections without walking the sidebar. */ +function useSectionKeys() { + const navigate = useNavigate() + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (!e.altKey || e.ctrlKey || e.metaKey) return + const item = NAV.find((n) => n.key === e.key) + if (!item || document.querySelector('dialog[open]')) return + e.preventDefault() + void navigate({ to: item.to }) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [navigate]) +} + +export function AppShell() { + useSectionKeys() + const { pathname } = useLocation() + const setup = useQuery(q.setup) + if (setup.data && !setup.data.configured && pathname !== '/setup') return + + return ( + + + Skip to content + +
+ +
+
+ + + + +
+ +
+ +
+
+
+
+ ) +} diff --git a/web/src/shell/Health.test.tsx b/web/src/shell/Health.test.tsx new file mode 100644 index 0000000..499bdc6 --- /dev/null +++ b/web/src/shell/Health.test.tsx @@ -0,0 +1,80 @@ +import { act, 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 { apiError, renderClient, stubApi } from '../test/harness' +import { HealthBadges, StaleBanner } from './Health' + +const server = (over = {}) => ({ stale: false, started_at: '2026-10-10T10:00:00Z', version: '1.2.3', ...over }) +const daemon = (state: string) => ({ state, started_at: null, pid: null, journal_tail: [], restart_available_at: null }) + +describe('HealthBadges', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('is neutral while nothing has loaded', () => { + stubApi({ 'GET /api/server': () => new Promise(() => {}), 'GET /api/daemon': () => new Promise(() => {}) }) + renderClient() + expect(screen.getByText('Server ok')).toBeInTheDocument() + expect(screen.getByText('Daemon …')).toHaveClass('pill-neutral') + expect(screen.getByText('Server ok')).not.toHaveAttribute('title') + }) + + it('shows a healthy server with its version, and a running daemon', async () => { + stubApi({ 'GET /api/server': server(), 'GET /api/daemon': daemon('running') }) + renderClient() + expect(await screen.findByText('Daemon running')).toHaveClass('pill-success') + expect(screen.getByText('Server ok')).toHaveAttribute('title', 'v1.2.3') + }) + + it('flags a stale server and a restarting daemon', async () => { + stubApi({ 'GET /api/server': server({ stale: true }), 'GET /api/daemon': daemon('restarting') }) + renderClient() + expect(await screen.findByText('Server stale')).toHaveClass('pill-warning') + expect(screen.getByText('Daemon restarting')).toHaveClass('pill-warning') + }) + + it('flags a dead server and an unreachable daemon', async () => { + stubApi({ 'GET /api/server': apiError(500, 'op_failed'), 'GET /api/daemon': daemon('unreachable') }) + renderClient() + expect(await screen.findByText('Server down')).toHaveClass('pill-danger') + expect(await screen.findByText('Daemon unreachable')).toHaveClass('pill-danger') + }) +}) + +describe('StaleBanner', () => { + const reload = vi.fn() + afterEach(() => { + reload.mockClear() + vi.unstubAllGlobals() + }) + + it('is absent while the code is current, or unknown', async () => { + stubApi({ 'GET /api/server': server() }) + renderClient() + await act(async () => {}) + expect(screen.queryByRole('status')).toBeNull() + }) + + it('offers a restart, then reloads once the new process answers', async () => { + vi.stubGlobal('location', { ...window.location, reload }) + const routes: Record = { 'GET /api/server': server({ stale: true }), 'POST /api/server/restart': undefined } + stubApi(routes) + renderClient() + await userEvent.click(await screen.findByRole('button', { name: 'Restart server' })) + expect(await screen.findByRole('button', { name: 'Restarting…' })).toBeDisabled() + expect(reload).not.toHaveBeenCalled() + routes['GET /api/server'] = server({ stale: false, started_at: '2026-10-10T11:00:00Z' }) + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ['server'] }) + }) + await waitFor(() => expect(reload).toHaveBeenCalledTimes(1)) + }) + + it('shows why a restart was refused', async () => { + stubApi({ 'GET /api/server': server({ stale: true }), 'POST /api/server/restart': apiError(429, 'rate_limited', 'too soon') }) + renderClient() + await userEvent.click(await screen.findByRole('button', { name: 'Restart server' })) + expect(await screen.findByText('too soon')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Restart server' })).toBeEnabled() + }) +}) diff --git a/web/src/shell/Health.tsx b/web/src/shell/Health.tsx new file mode 100644 index 0000000..14140f9 --- /dev/null +++ b/web/src/shell/Health.tsx @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' +import { q } from '../api/queries' +import { ErrorNotice } from '../ui/Notice' +import { useServerRestart } from './useServerRestart' + +const DAEMON_TONE = { running: 'success', restarting: 'warning', unreachable: 'danger' } as const + +export function HealthBadges() { + const server = useQuery(q.server) + const daemon = useQuery(q.daemon) + const serverTone = server.error ? 'danger' : server.data?.stale ? 'warning' : 'success' + const serverText = server.error ? 'Server down' : server.data?.stale ? 'Server stale' : 'Server ok' + const d = daemon.data?.state + return ( +
+ + {serverText} + + Daemon {d ?? '…'} +
+ ) +} + +/** Banner shown on every screen while the running server is older than the code on disk. */ +export function StaleBanner() { + const server = useQuery(q.server) + const { restart, restarting } = useServerRestart() + if (!server.data?.stale) return null + return ( +
+ + The web server is running stale code. Restart it to pick up the new version. + + + {restart.error && } +
+ ) +} diff --git a/web/src/shell/JobIndicator.test.tsx b/web/src/shell/JobIndicator.test.tsx new file mode 100644 index 0000000..3c92d6d --- /dev/null +++ b/web/src/shell/JobIndicator.test.tsx @@ -0,0 +1,62 @@ +import { act, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makeJob } from '../test/fixtures' +import { FakeEventSource, installEventSource, renderRouted, stubApi } from '../test/harness' +import { JobIndicator } from './JobIndicator' + +describe('JobIndicator', () => { + beforeEach(installEventSource) + afterEach(() => vi.unstubAllGlobals()) + + it('renders nothing while no job is active', async () => { + stubApi({ 'GET /api/jobs': [makeJob({ id: 'a', state: 'succeeded' })] }) + renderRouted() + await act(async () => {}) + expect(screen.queryByLabelText('Running jobs')).toBeNull() + }) + + it('renders nothing before the list loads', () => { + stubApi({ 'GET /api/jobs': () => new Promise(() => {}) }) + renderRouted() + expect(screen.queryByLabelText('Running jobs')).toBeNull() + }) + + it('shows at most two running jobs as links with their progress', async () => { + stubApi({ + 'GET /api/jobs': [ + makeJob({ id: 'a', command: 'scan', progress: { step: 2, total: 8, label: 'Scanning' } }), + makeJob({ id: 'b', command: 'check', progress: { step: 1, total: null, label: 'Checking' } }), + makeJob({ id: 'c', command: 'done' }), + ], + }) + renderRouted() + const chips = await screen.findAllByRole('link') + expect(chips).toHaveLength(2) + expect(chips[0]).toHaveAccessibleName('Scan: Scanning') + expect(chips[0]).toHaveTextContent('2/8') + expect(chips[0]).toHaveAttribute('href', '/jobs/a') + expect(chips[1]).toHaveTextContent('…') + }) + + it('says "running" for a job with no progress yet', async () => { + stubApi({ 'GET /api/jobs': [makeJob({ id: 'a', command: 'scan' })] }) + renderRouted() + expect(await screen.findByRole('link')).toHaveAccessibleName('Scan: running') + }) + + it('follows live events and flags a job that needs input', async () => { + stubApi({ 'GET /api/jobs': [makeJob({ id: 'live-chip', command: 'scan' })] }) + renderRouted() + await screen.findByRole('link') + act(() => { + FakeEventSource.last.emit('progress', { seq: 1, ts: 't', type: 'progress', step: 3, total: 6, label: 'Halfway' }) + }) + expect(screen.getByRole('link')).toHaveTextContent('3/6') + act(() => { + FakeEventSource.last.emit('state', { seq: 2, ts: 't', type: 'state', state: 'waiting_input' }) + }) + expect(screen.getByRole('link')).toHaveAccessibleName('Scan: needs your input') + expect(screen.getByRole('link')).toHaveClass('jobchip-wait') + expect(screen.getByRole('link')).toHaveTextContent('needs input') + }) +}) diff --git a/web/src/shell/JobIndicator.tsx b/web/src/shell/JobIndicator.tsx new file mode 100644 index 0000000..23a9a8e --- /dev/null +++ b/web/src/shell/JobIndicator.tsx @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query' +import { Link } from '@tanstack/react-router' +import type { Job } from '../api/jobContract' +import { isActive, q } from '../api/queries' +import { commandLabel } from '../commands/catalog' +import { ProgressBar } from '../jobs/ProgressBar' +import { useLiveJob } from '../jobs/useLiveJob' + +/** Top-bar chip for each running job, live from its SSE stream. */ +function Chip({ job }: { job: Job }) { + const live = useLiveJob(job.id) + const state = live?.state ?? job.state + const p = live?.progress ?? job.progress + const fraction = p && p.total ? p.step / p.total : null + const waiting = state === 'waiting_input' + return ( + + + {commandLabel(job.command)} + {waiting ? 'needs input' : p?.total ? `${p.step}/${p.total}` : '…'} + + + + ) +} + +export function JobIndicator() { + const { data } = useQuery(q.jobs) + const active = (data ?? []).filter(isActive).slice(0, 2) + if (active.length === 0) return null + return ( +
+ {active.map((j) => ( + + ))} +
+ ) +} diff --git a/web/src/shell/nav.ts b/web/src/shell/nav.ts new file mode 100644 index 0000000..a84f95a --- /dev/null +++ b/web/src/shell/nav.ts @@ -0,0 +1,20 @@ +import type { SectionPath } from '../commands/catalog' + +export interface NavItem { + to: SectionPath | '/jobs' + label: string + blurb: string + /** Alt+ accelerator, shown in the sidebar. */ + key: string +} + +export const NAV: NavItem[] = [ + { to: '/', label: 'Dashboard', blurb: 'Status, budget and quick actions', key: '1' }, + { to: '/backlog', label: 'Backlog', blurb: 'Planner, stats and the full game list', key: '2' }, + { to: '/picks', label: 'Picks', blurb: 'Check, done, pick, scan, manual picks', key: '3' }, + { to: '/gaming', label: 'Gaming', blurb: 'Daily budget, reset, block, unblock', key: '4' }, + { to: '/library', label: 'Library', blurb: 'All your games, installed, install, uninstall, hide', key: '5' }, + { to: '/store', label: 'Store', blurb: 'Unblock, buy DLC, exceptions', key: '6' }, + { to: '/jobs', label: 'Jobs', blurb: 'Running and past jobs', key: '7' }, + { to: '/system', label: 'System', blurb: 'Daemon, server, backups, reset, setup', key: '8' }, +] diff --git a/web/src/shell/useServerRestart.test.tsx b/web/src/shell/useServerRestart.test.tsx new file mode 100644 index 0000000..6286179 --- /dev/null +++ b/web/src/shell/useServerRestart.test.tsx @@ -0,0 +1,34 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, 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 { stubApi } from '../test/harness' +import { useServerRestart } from './useServerRestart' + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +) + +describe('useServerRestart', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('reloads when the first answer arrives after a restart asked before any answer', async () => { + const reload = vi.fn() + vi.stubGlobal('location', { ...window.location, reload }) + let answer: (v: unknown) => void = () => {} + stubApi({ + 'GET /api/server': () => new Promise((r) => (answer = r)), + 'POST /api/server/restart': undefined, + }) + const { result } = renderHook(() => useServerRestart(), { wrapper }) + expect(result.current.restarting).toBe(false) + await act(async () => { + result.current.restart.mutate() + }) + await waitFor(() => expect(result.current.restarting).toBe(true)) + expect(reload).not.toHaveBeenCalled() + await act(async () => answer({ stale: false, started_at: '2026-10-10T12:00:00Z', version: '1' })) + await waitFor(() => expect(reload).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/web/src/shell/useServerRestart.ts b/web/src/shell/useServerRestart.ts new file mode 100644 index 0000000..ab0d57f --- /dev/null +++ b/web/src/shell/useServerRestart.ts @@ -0,0 +1,23 @@ +import { useMutation, useQuery } from '@tanstack/react-query' +import { useEffect, useState } from 'react' +import { api } from '../api/client' +import { q } from '../api/queries' + +/** + * Restart the web server. Its token changes with the process, so once the + * new instance answers with a later `started_at` the page reloads to pick + * the new token up from index.html. + */ +export function useServerRestart() { + const server = useQuery(q.server) + const [waitingFor, setWaitingFor] = useState(null) + const restart = useMutation({ + mutationFn: api.restartServer, + onSuccess: () => setWaitingFor(server.data?.started_at ?? ''), + }) + const startedAt = server.data?.started_at + useEffect(() => { + if (waitingFor !== null && startedAt && startedAt !== waitingFor) window.location.reload() + }, [waitingFor, startedAt]) + return { restart, restarting: waitingFor !== null } +} diff --git a/web/src/styles/app.css b/web/src/styles/app.css new file mode 100644 index 0000000..b967cbe --- /dev/null +++ b/web/src/styles/app.css @@ -0,0 +1,186 @@ +/* Control-UI shell and components. Only tokens from ./tokens.css; any + * derived colour is a color-mix of tokens. Type sizes are on the 12/14/16/ + * 20/24/32 scale, spacing on 4/8/16/24/32/48. */ + +/* ── Utilities ── */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.muted { color: var(--text-muted); } +.mono { font-family: var(--font-mono); } +.m0 { margin: 0; } +.grow { flex: 1 1 auto; min-width: 0; } +.ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.text-success { color: var(--success); } +.text-warning { color: var(--warning); } +.text-danger { color: var(--danger); } +.stack { display: flex; flex-direction: column; gap: var(--space-3); } +.h3 { font-size: var(--text-body); margin: var(--space-3) 0 var(--space-2); font-weight: 600; } +kbd { + font-family: var(--font-mono); + font-size: var(--text-caption); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0 var(--space-1); +} + +.skip { + position: absolute; + left: var(--space-3); + top: calc(-1 * var(--space-6)); + z-index: 10; + background: var(--accent); + color: var(--on-fill); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); +} +.skip:focus { top: var(--space-2); } + +/* ── Shell ── */ +.shell { + display: grid; + grid-template-columns: 13rem minmax(0, 1fr); + min-height: 100dvh; +} +.sidebar { + position: sticky; + top: 0; + height: 100dvh; + overflow-y: auto; + background: var(--surface-1); + border-right: 1px solid var(--border); + padding: var(--space-3) var(--space-2); +} +.brand { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 0 var(--space-2) var(--space-4); + font-weight: 650; + line-height: 1.2; +} +.brand-mark { + width: var(--space-4); + height: var(--space-4); + border-radius: var(--radius-sm); + background: var(--accent); + flex: none; +} +.nav { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); } +.nav-link { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + color: var(--text-muted); + transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); +} +.nav-link:hover { background: var(--surface-2); color: var(--text); text-decoration: none; } +.nav-link.active { background: var(--surface-2); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); } +.nav-link kbd { opacity: 0.7; } + +.main-col { min-width: 0; display: flex; flex-direction: column; } +.topbar { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + gap: var(--space-3); + min-height: calc(var(--space-6) + var(--space-2)); + padding: var(--space-2) var(--space-4); + background: var(--bg); + border-bottom: 1px solid var(--border); +} +.main { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-4); max-width: 1400px; width: 100%; } +.main:focus { outline: none; } +.health { display: flex; gap: var(--space-2); } +.palette-btn kbd { margin-left: var(--space-2); } + +@media (max-width: 800px) { + .shell { grid-template-columns: minmax(0, 1fr); } + .sidebar { position: static; height: auto; border-right: 0; border-bottom: 1px solid var(--border); padding: var(--space-2); } + .brand { display: none; } + .nav { flex-direction: row; overflow-x: auto; } + .nav-link kbd { display: none; } +} + +/* ── Banners / notices ── */ +.banner { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + border-bottom: 1px solid color-mix(in srgb, var(--warning) 40%, var(--border)); + background: color-mix(in srgb, var(--warning) 12%, var(--bg)); +} +.notice { + border-radius: var(--radius-sm); + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-2); + max-width: var(--measure); +} +.notice-title { font-weight: 600; } +.notice-text { display: flex; flex-direction: column; gap: var(--space-2); } +.notice-info { background: color-mix(in srgb, var(--accent) 10%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } +.notice-success { background: color-mix(in srgb, var(--success) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--success) 40%, var(--border)); } +.notice-warning { background: color-mix(in srgb, var(--warning) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--warning) 40%, var(--border)); } +.notice-danger { background: color-mix(in srgb, var(--danger) 12%, var(--surface-1)); border: 1px solid color-mix(in srgb, var(--danger) 40%, var(--border)); } + +.cmdline { + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--space-1) var(--space-1) var(--space-1) var(--space-3); +} +.cmdline code { background: none; padding: 0; flex: 1; } + +.loading { color: var(--text-muted); display: flex; align-items: center; gap: var(--space-2); margin: 0; } +.spinner { + width: var(--space-3); + height: var(--space-3); + border-radius: 50%; + border: 2px solid var(--border); + border-top-color: var(--accent); + animation: spin 800ms linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +.empty { color: var(--text-muted); margin: 0; } + +/* ── Pills ── */ +.pill { + display: inline-flex; + align-items: center; + font-size: var(--text-caption); + letter-spacing: 0.04em; + font-weight: 600; + padding: 0 var(--space-2); + border-radius: 999px; + white-space: nowrap; + vertical-align: middle; +} +.pill + .pill { margin-left: var(--space-1); } +.pill-neutral { color: var(--text-muted); background: var(--surface-2); } +.pill-accent { color: var(--accent); background: color-mix(in srgb, var(--accent) 16%, transparent); } +.pill-success { color: var(--success); background: color-mix(in srgb, var(--success) 16%, transparent); } +.pill-warning { color: var(--warning); background: color-mix(in srgb, var(--warning) 16%, transparent); } +.pill-danger { color: var(--danger); background: color-mix(in srgb, var(--danger) 16%, transparent); } +.tags { display: flex; flex-wrap: wrap; gap: var(--space-1); } +.tags .pill + .pill { margin-left: 0; } + +/* Keep keyboard-focused elements clear of the sticky top bar (operability rule 4). */ +html { scroll-padding-top: calc(var(--space-6) + var(--space-3)); } diff --git a/web/src/styles/controls.css b/web/src/styles/controls.css new file mode 100644 index 0000000..3872ad9 --- /dev/null +++ b/web/src/styles/controls.css @@ -0,0 +1,127 @@ +/* Buttons, form fields, dialogs and the command palette. */ + +/* ── Buttons: vertical 12 / horizontal 24 (2:1), small 4 / 8 ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + font: inherit; + font-size: var(--text-body); + line-height: 1.25; + padding: 12px var(--space-4); + border-radius: var(--radius-sm); + border: 1px solid transparent; + cursor: pointer; + white-space: nowrap; + text-decoration: none; + transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); +} +.btn:hover { text-decoration: none; } +.btn-primary { background: var(--accent); color: var(--on-fill); } +.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, var(--text)); } +.btn-danger { background: var(--danger); color: var(--on-fill); } +.btn-danger:hover { background: color-mix(in srgb, var(--danger) 88%, var(--text)); } +.btn-ghost { background: var(--surface-1); color: var(--text); border-color: var(--border); } +.btn-ghost:hover { border-color: var(--accent); } +.btn-small { font-size: var(--text-label); padding: var(--space-1) var(--space-2); } +.btn-icon { padding: var(--space-1) var(--space-2); font-size: var(--text-subtitle); line-height: 1; } +.btn:disabled, +.btn[aria-disabled='true'] { opacity: 0.5; cursor: not-allowed; } +.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; } + +/* ── Form fields ── */ +.form-field { display: flex; flex-direction: column; gap: var(--space-1); max-width: var(--measure); } +.field-label { font-size: var(--text-label); letter-spacing: var(--tracking-label); font-weight: 600; } +.field-help { font-size: var(--text-label); color: var(--text-muted); margin: 0; min-height: 1.5em; } +.input { + font: inherit; + font-size: var(--text-body); + color: var(--text); + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--space-2) 12px; + width: 100%; + transition: border-color var(--duration-fast) var(--ease-standard); +} +.input:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } +.input:disabled { opacity: 0.5; } +.input-num { max-width: 16rem; } +.input-ok { border-color: var(--success); } +.input-bad { border-color: var(--danger); } +.input.mono { font-family: var(--font-mono); } + +.phrase { display: flex; flex-direction: column; gap: var(--space-1); max-width: var(--measure); } +.phrase-target { + margin: 0; + font-family: var(--font-mono); + font-size: var(--text-body); + padding: var(--space-2) 12px; + border-radius: var(--radius-sm); + background: var(--surface-2); + color: var(--text-muted); + white-space: pre-wrap; + user-select: none; +} +.phrase-target .ok { color: var(--success); } +.phrase-target .bad { color: var(--on-fill); background: var(--danger); border-radius: 2px; } + +.choices { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--space-2); } +.choice { + display: grid; + grid-template-columns: auto 1fr; + gap: 0 var(--space-2); + align-items: center; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + cursor: pointer; +} +.choice.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } +.choice input { accent-color: var(--accent); margin: 0; } +.choice-detail { grid-column: 2; font-size: var(--text-label); color: var(--text-muted); } + +/* ── Dialog (native ) ── */ +.dialog { + width: min(40rem, calc(100vw - var(--space-5))); + max-height: calc(100dvh - var(--space-5)); + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface-1); + color: var(--text); + overflow: auto; +} +.dialog-wide { width: min(48rem, calc(100vw - var(--space-5))); } +.dialog::backdrop { background: color-mix(in srgb, var(--bg) 75%, transparent); } +.dialog[open] { animation: dialog-in var(--duration-slow) var(--ease-decelerate); } +@keyframes dialog-in { from { opacity: 0; transform: translateY(var(--space-2)); } } +.dialog-body { padding: var(--space-4); display: flex; flex-direction: column; gap: var(--space-3); } +.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.dialog-head h2 { font-size: var(--text-subtitle); margin: 0; } + +/* ── Command palette ── */ +.palette-input { font-size: var(--text-subtitle); } +.palette-list { + list-style: none; + margin: 0; + padding: 0; + max-height: min(26rem, calc(100dvh - 14rem)); + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; +} +.palette-item { + display: grid; + grid-template-columns: 1fr auto; + gap: 0 var(--space-3); + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + cursor: pointer; +} +.palette-item.active { background: var(--surface-2); box-shadow: inset 3px 0 0 var(--accent); } +.palette-title { font-weight: 600; display: flex; gap: var(--space-2); align-items: center; } +.palette-detail { grid-column: 1; font-size: var(--text-label); color: var(--text-muted); } +.palette-hint { grid-row: 1 / span 2; grid-column: 2; align-self: center; font-size: var(--text-caption); letter-spacing: var(--tracking-caption); text-transform: uppercase; color: var(--text-muted); } diff --git a/web/src/styles/library.css b/web/src/styles/library.css new file mode 100644 index 0000000..c5b91b2 --- /dev/null +++ b/web/src/styles/library.css @@ -0,0 +1,104 @@ +/* ── Library browser: cover grid, search combobox, pick-mode prompt ── */ + +.lib { display: flex; flex-direction: column; gap: var(--space-3); } +.lib-toolbar { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-3); +} +.lib-search { position: relative; flex: 1 1 20rem; min-width: 16rem; } +.lib-sort { display: flex; flex-direction: column; gap: var(--space-1); } +.lib-filters { display: flex; flex-wrap: wrap; gap: var(--space-3); border: 0; margin: 0; padding: 0; } +.lib-count { margin-left: auto; font-size: var(--text-label); } + +.lib-suggest { + position: absolute; + z-index: 5; + inset: calc(100% + var(--space-1)) 0 auto 0; + margin: 0; + padding: var(--space-1); + list-style: none; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.lib-suggest-item { + display: flex; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2); + border-radius: var(--radius-sm); + cursor: pointer; +} +.lib-suggest-item.active, +.lib-suggest-item:hover { background: color-mix(in srgb, var(--accent) 16%, transparent); } +.lib-suggest-meta { color: var(--text-muted); font-size: var(--text-label); white-space: nowrap; } + +.game-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + gap: var(--space-3); +} +.game-card { + /* Off-screen cards skip layout and paint: 1000+ covers scroll smoothly. */ + content-visibility: auto; + contain-intrinsic-size: auto 22rem; + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-2); + text-align: left; + font: inherit; + color: var(--text); + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color var(--duration-fast) var(--ease-standard); +} +.game-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } +.game-card.selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, var(--surface-1)); } +.game-card.ineligible .cover, +.game-card.ineligible .game-title, +.game-card.ineligible .game-meta { opacity: 0.45; } +.game-card.ineligible .cover { filter: grayscale(1); } + +.cover { + display: block; + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: var(--radius-sm); + background: var(--surface-2); +} +.cover-tile { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-3); + text-align: center; + font-size: var(--text-subtitle); + font-weight: 600; + overflow: hidden; + overflow-wrap: anywhere; +} +.game-title { + font-weight: 600; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.game-meta, .game-reason { font-size: var(--text-label); color: var(--text-muted); } +.game-reason { color: var(--warning); } +.game-badges { display: flex; flex-wrap: wrap; } + +/* Pick mode lives in the runner dialog: widen it and give the grid its own + * scroll region so the answer buttons stay in view. */ +.dialog:has(.prompt-game) { width: min(80rem, calc(100vw - var(--space-5))); } +.prompt-game .game-grid { max-height: 60dvh; overflow: auto; padding: var(--space-1); } +.prompt-game-bar { position: sticky; bottom: 0; background: var(--surface-1); padding-top: var(--space-2); } + +.seg { display: inline-flex; gap: var(--space-1); padding: var(--space-1); background: var(--surface-1); border-radius: var(--radius-sm); } +.seg .btn[aria-pressed='true'] { background: var(--surface-2); color: var(--text); } diff --git a/web/src/styles/screens.css b/web/src/styles/screens.css new file mode 100644 index 0000000..91d252b --- /dev/null +++ b/web/src/styles/screens.css @@ -0,0 +1,146 @@ +/* Page layout pieces, command cards, job progress, countdown. */ + +/* ── Page ── */ +.page-head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: var(--space-3); } +.page-head h1 { font-size: var(--text-title); letter-spacing: var(--tracking-title); } +.page-head .sub { margin: var(--space-1) 0 0; max-width: var(--measure); } +.page-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); } + +.box { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-4); + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 0; +} +.box.narrow { max-width: 40rem; } +.box-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); } +.box-head h2 { margin: 0; font-size: var(--text-subtitle); } +.grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr)); gap: var(--space-4); } + +.facts { margin: 0; display: flex; flex-direction: column; } +.fact { display: flex; justify-content: space-between; gap: var(--space-3); padding: var(--space-2) 0; border-top: 1px solid var(--border); } +.fact:first-child { border-top: 0; } +.fact dt { color: var(--text-muted); } +.fact dd { margin: 0; text-align: right; } + +.hero-game { font-size: var(--text-title); letter-spacing: var(--tracking-title); font-weight: 650; margin: 0; } +.hero-num { font-size: var(--text-title); font-weight: 650; margin: 0; font-variant-numeric: tabular-nums; } +.hero-num .muted { font-size: var(--text-body); font-weight: 400; } +.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); gap: var(--space-3); } +.stat { display: flex; flex-direction: column; } +.stat-num { font-size: var(--text-title); font-weight: 650; font-variant-numeric: tabular-nums; } +.stat-label { font-size: var(--text-label); color: var(--text-muted); } + +.rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.row, +.row-link { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; border-top: 1px solid var(--border); min-width: 0; } +.rows li:first-child > .row-link, +.rows .row:first-child { border-top: 0; } +.row-link { color: var(--text); border-radius: var(--radius-sm); } +.row-link:hover { text-decoration: none; background: var(--surface-2); } + +.tbl { overflow-x: auto; } +.planner-wrap { container: planner / inline-size; } +.planner-wrap .app-head h1 { font-size: var(--text-subtitle); } +.planner-wrap .panel { top: calc(var(--space-6) + var(--space-2) + var(--space-3)); } + +/* ── Command cards ── */ +.actions-block h2 { font-size: var(--text-subtitle); margin: 0 0 var(--space-3); } +.cmd-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); gap: var(--space-3); } +.cmd-card { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-2); +} +.cmd-card .btn { margin-top: auto; align-self: flex-start; } +.cmd-card.locked { border-style: dashed; } +.cmd-card.locked .cmd-desc { color: var(--warning); } +.cmd-name { margin: 0; font-size: var(--text-body); display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; } +.cmd-desc { margin: 0; color: var(--text-muted); font-size: var(--text-label); } + +/* ── Progress bar ── */ +.bar { height: var(--space-2); border-radius: 999px; background: var(--surface-2); overflow: hidden; position: relative; } +.bar-small { height: var(--space-1); } +.bar-fill { height: 100%; width: 0; border-radius: inherit; background: var(--accent); transition: width var(--duration-base) var(--ease-standard); } +.bar-success .bar-fill { background: var(--success); } +.bar-warning .bar-fill { background: var(--warning); } +.bar-danger .bar-fill { background: var(--danger); } +.bar-indeterminate .bar-fill { width: 35%; animation: sweep 1400ms var(--ease-standard) infinite; } +@keyframes sweep { from { transform: translateX(-100%); } to { transform: translateX(300%); } } +@media (prefers-reduced-motion: reduce) { + .bar-indeterminate .bar-fill { animation: none; width: 100%; opacity: 0.4; } + .spinner { animation: none; } +} + +/* ── Job ── */ +.job { display: flex; flex-direction: column; gap: var(--space-2); } +.job-head { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; } +.job-title { margin: 0; font-size: var(--text-subtitle); } +.job-meta { margin-left: auto; display: flex; align-items: center; gap: var(--space-2); font-family: var(--font-mono); color: var(--text-muted); font-variant-numeric: tabular-nums; } +.job-phase { display: flex; justify-content: space-between; gap: var(--space-3); align-items: baseline; margin-top: var(--space-2); } +.job-label { font-weight: 600; } +.job-step { font-family: var(--font-mono); color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; } +.job-sub { display: flex; justify-content: space-between; gap: var(--space-3); font-size: var(--text-label); color: var(--text-muted); min-height: 1.5em; } +.job-item { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.job-eta { white-space: nowrap; font-variant-numeric: tabular-nums; } +.dot { width: var(--space-2); height: var(--space-2); border-radius: 50%; display: inline-block; } +.dot-live { background: var(--success); } +.dot-off { background: var(--warning); } +.prompt { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--warning); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--warning) 8%, var(--surface-1)); +} +.prompt-msg { margin: 0; font-weight: 600; } +.job-log summary { font-size: var(--text-label); color: var(--text-muted); cursor: pointer; padding: var(--space-1) 0; } +.log, .journal { + margin: 0; + max-height: 16rem; + overflow: auto; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: var(--space-2) var(--space-3); + font-family: var(--font-mono); + font-size: var(--text-label); + line-height: 1.6; +} +.log { list-style: none; } +.log time { color: var(--text-muted); } +.log-warning { color: var(--warning); } +.log-error { color: var(--danger); } +.journal { white-space: pre-wrap; max-height: 20rem; } + +/* Top-bar job chips */ +.jobchips { display: flex; gap: var(--space-2); min-width: 0; } +.jobchip { + display: flex; + flex-direction: column; + gap: var(--space-1); + width: 12rem; + padding: var(--space-1) var(--space-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-1); + color: var(--text); +} +.jobchip:hover { text-decoration: none; border-color: var(--accent); } +.jobchip-wait { border-color: var(--warning); } +.jobchip-label { display: flex; justify-content: space-between; gap: var(--space-2); font-size: var(--text-label); } +.jobchip-sub { color: var(--text-muted); font-family: var(--font-mono); } + +/* ── Countdown ── */ +.countdown-clock { display: flex; align-items: baseline; gap: var(--space-3); } +.countdown-num { font-size: var(--text-display); letter-spacing: var(--tracking-display); font-weight: 650; font-family: var(--font-mono); font-variant-numeric: tabular-nums; } diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..413d20c --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,151 @@ +/* + * The frozen design tokens, shared by every one of kuhy's web repos. + * + * Source of truth: ~/src/utils/unified-design-system/tokens.md (values) and + * README.md (the CSS custom-property names). The same numbers ship for Dart in + * ~/src/utils/design_system, so a change here is a change there — do not edit one + * side alone, and do not introduce a value that is not on a scale below. + * + * Copied from ~/src/utils/web_ui/src/tokens.css (values unchanged). + */ + +:root { + /* Neutrals ------------------------------------------------------------- */ + --bg: #211d1b; + --surface-1: #2b2624; + --surface-2: #38312e; + --border: #463e3a; + --text: #eceae9; + --text-muted: #aaa09a; + + /* Accent + semantic roles ---------------------------------------------- */ + --accent: #b8862e; + --info: #b8862e; /* = accent */ + --success: #8a9a3c; + --warning: #e0a63c; + --danger: #e2585f; + + /* Text/icons ON a filled accent/success/warning/danger surface. Never + * --text: every semantic fill sits in the same mid-light band, where + * near-white measures 2.2:1-3.6:1 against a 4.5:1 floor. */ + --on-fill: #211d1b; + /* Text/icons on a dark translucent scrim/overlay — fixed, not theme-tied, + * because the scrim itself is always dark. */ + --on-scrim: #eceae9; + + /* Categorical ramp ------------------------------------------------------ */ + /* For charts, tags and category dots — N mutually distinguishable colours + * with no inherent meaning. NEVER for semantic state; that stays + * success/warning/danger. Use them in ramp order for ordered data and by + * position for unordered data — never "the green one because green means + * good", which is the semantic vocabulary leaking back in. + * + * These deliberately leave the warm wedge every other token sits in: a + * categorical ramp has no meaning to lean on, so hue separation IS the + * requirement. Chosen by search under a >=20 CIE dE floor across normal + * vision and all three CVD types, >=3:1 on BOTH backgrounds, and a monotonic + * lightness staircase (58 -> 44) so the ramp survives greyscale. Do not edit + * a value here without re-running unified-design-system/scripts/ramp_check.py. + * They do not flip with prefers-color-scheme — they must hold on both. */ + --cat-1: #c57293; + --cat-2: #398fc0; + --cat-3: #c85a32; + --cat-4: #228736; + --cat-5: #8d58bb; + --cat-6: #686d2c; + + /* Corner radius --------------------------------------------------------- */ + --radius-sm: 8px; /* buttons, inputs, chips */ + --radius-md: 12px; /* cards */ + --radius-lg: 16px; /* sheets, dialogs */ + + /* Spacing (4px base) ---------------------------------------------------- */ + --space-1: 4px; + --space-2: 8px; + --space-3: 16px; + --space-4: 24px; + --space-5: 32px; + --space-6: 48px; + + /* Typography ------------------------------------------------------------ */ + --font-sans: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif; + --font-mono: ui-monospace, "Cascadia Code", monospace; + + --text-display: 32px; + --text-title: 24px; + --text-subtitle: 20px; + --text-body: 16px; /* the floor for anything a user actually reads */ + --text-label: 14px; /* UI chrome only */ + --text-caption: 12px; /* UI chrome only */ + + --tracking-display: -0.2px; + --tracking-title: -0.1px; + --tracking-label: 0.05em; + --tracking-caption: 0.1em; + + /* Prose line-length cap (rule 21): ~65-70 characters. */ + --measure: 40rem; + + /* Shadows: light surfaces only, and only on floating/overlay elements — + * never inline cards or list rows. Blur = 2x offset, tinted from ink. */ + --shadow-1: 0 2px 4px rgba(33, 29, 27, 0.12); /* dropdown */ + --shadow-2: 0 4px 8px rgba(33, 29, 27, 0.16); /* modal */ + + /* Focus ring: the platform default is black, invisible on --bg. */ + --focus-ring: #b8862e; /* = accent */ + + /* Motion ----------------------------------------------------------------- */ + /* Frozen in ~/src/utils/unified-design-system/motion.md, which carries the + * rationale per step. The same numbers ship for Dart as AppDuration / + * AppCurve — a change here is a change there. Four steps only: a longer + * duration vocabulary gets picked from at random, which is drift wearing a + * token's name. */ + --duration-instant: 0ms; /* the reduced-motion collapse target */ + --duration-fast: 120ms; /* hover, press, checkbox — already under the cursor */ + --duration-base: 200ms; /* default: enters/leaves/moves within the view */ + --duration-slow: 320ms; /* sheets, dialogs, page transitions */ + + /* Named for what the motion does at its END — the part the eye reads. Never + * ease-in-out on something that enters or exits: symmetric easing makes an + * arrival look like it is braking and a departure look reluctant. */ + --ease-standard: cubic-bezier(0.2, 0, 0, 1); /* starts and ends on screen */ + --ease-decelerate: cubic-bezier(0, 0, 0, 1); /* entering: full speed -> rest */ + --ease-accelerate: cubic-bezier(0.3, 0, 1, 1); /* leaving: no settle */ +} + +/* This app ships dark only (the budget charts are tuned for ink), so the + * light override from ~/src/utils/web_ui is deliberately omitted and dark + * surfaces take no shadow at all; elevation is a fill step. */ +:root { + color-scheme: dark; + --shadow-1: none; + --shadow-2: none; +} + +/* Mandatory, and it collapses the tokens themselves rather than asking every + * consumer to branch. Easing is irrelevant at zero duration, so the curves are + * left alone. Sound and haptics are NOT motion and are deliberately untouched + * here: a user who suppressed animation did not ask to lose confirmation that + * a tap landed. The sound opt-out is a separate, user-facing setting. */ +@media (prefers-reduced-motion: reduce) { + :root { + --duration-fast: 0ms; + --duration-base: 0ms; + --duration-slow: 0ms; + } +} + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + font-size: var(--text-body); +} + +/* Ships here rather than being left to each consumer: an unstyled focus ring + * is the accessibility regression this layer exists to prevent, and several of + * these apps are keyboard-only by necessity. */ +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 1px; +} diff --git a/web/src/test/dialogRoutes.ts b/web/src/test/dialogRoutes.ts new file mode 100644 index 0000000..404ec36 --- /dev/null +++ b/web/src/test/dialogRoutes.ts @@ -0,0 +1,18 @@ +// Routes every part of the runner dialog reads (catalog, game lookup, jobs). + +import type { CommandSpec } from '../api/contract' +import { makeDataset, makeGame } from './factories' +import { makeInstalled, makeJob, makeStatus } from './fixtures' + +/** Routes every screen of the dialog reads, plus whatever the test adds. */ +export function routes(specs: CommandSpec[], extra: Record = {}) { + return { + 'GET /api/commands': specs, + 'GET /api/dataset': makeDataset([makeGame({ app_id: 7, name: 'Seven' })]), + 'GET /api/installed': { games: [makeInstalled({ app_id: 8, name: 'Eight' }), makeInstalled({ app_id: 9, name: 'Nine', assigned: true })] }, + 'GET /api/status': makeStatus(), + 'GET /api/jobs': [], + 'GET /api/jobs/j1': makeJob({ id: 'j1' }), + ...extra, + } +} diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts new file mode 100644 index 0000000..40fd3b1 --- /dev/null +++ b/web/src/test/fixtures.ts @@ -0,0 +1,90 @@ +// Fixtures for the web-control API shapes (api/contract.ts, jobContract.ts). +// Lives under src/test/, excluded from the app build and from coverage. + +import type { + CommandName, + CommandSpec, + InstalledGame, + LibraryGame, + StatusPayload, +} from '../api/contract' +import type { Job, PendingAction } from '../api/jobContract' + +export function makeSpec(name: CommandName, over: Partial = {}): CommandSpec { + return { + name, + description: `Does ${name}.`, + category: 'backlog', + kind: 'job', + params: [], + friction: null, + privileged: false, + mutating: true, + cancellable: false, + locked_reason: null, + ...over, + } +} + +export function makeLibGame(over: Partial = {}): LibraryGame { + return { + app_id: 1, + name: 'Game', + achievements_total: 10, + achievements_unlocked: 5, + hltb_hours: 12, + playtime_minutes: 192, + last_played: 1_700_000_000, + installed: false, + assigned: false, + ineligible_reason: null, + ...over, + } +} + +export function makeInstalled(over: Partial = {}): InstalledGame { + return { app_id: 1, name: 'Game', size_bytes: 1024 ** 3, assigned: false, protected: false, ...over } +} + +export function makeStatus(over: Partial = {}): StatusPayload { + return { + current_app_id: 10, + current_game_name: 'Hollow Knight', + finished_count: 3, + store_blocked: true, + installed_count: 4, + assigned_game_installed: true, + total_block: { active: false, days_remaining: 0, until: null }, + manual_pick_locked: false, + manual_picks: [], + ...over, + } +} + +export function makeJob(over: Partial = {}): Job { + return { + id: 'j1', + command: 'scan', + params: {}, + state: 'running', + created_at: '2026-10-10T10:00:00Z', + started_at: '2026-10-10T10:00:01Z', + ended_at: null, + exit_code: null, + progress: null, + summary: null, + ...over, + } +} + +export function makePending(over: Partial = {}): PendingAction { + return { + id: 'p1', + command: 'gaming-reset', + phrase: 'reset gaming now', + armed_at: '2026-10-10T10:00:00Z', + ready_at: '2026-10-10T10:05:00Z', + heartbeat_interval_seconds: 10, + ...over, + } +} diff --git a/web/src/test/harness.tsx b/web/src/test/harness.tsx new file mode 100644 index 0000000..17ef592 --- /dev/null +++ b/web/src/test/harness.tsx @@ -0,0 +1,143 @@ +// Test harness: a routed fetch stub, a fake EventSource and a render helper +// that provides the query client and a memory-history router. Lives under +// src/test/, excluded from the app build and from coverage. + +import { QueryClientProvider } from '@tanstack/react-query' +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterProvider, +} from '@tanstack/react-router' +import { render } from '@testing-library/react' +import type { ReactNode } from 'react' +import { vi } from 'vitest' +import { queryClient } from '../api/queries' + +export interface Req { + method: string + path: string + body: unknown +} +/** A value, or a function of the request that may return a value, a Reply or a Promise. */ +type Handler = unknown | ((r: Req) => unknown) + +/** A non-200 reply: `new Reply(409, { error: 'busy', message: '' })`. */ +export class Reply { + readonly status: number + readonly body: unknown + readonly raw: string | undefined + constructor(status: number, body?: unknown, raw?: string) { + this.status = status + this.body = body + this.raw = raw + } +} + +export const apiError = (status: number, error: string, message = '') => + new Reply(status, { error, message }) + +async function respond(h: Handler, req: Req) { + const out = await (typeof h === 'function' ? (h as (r: Req) => unknown)(req) : h) + const r = out instanceof Reply ? out : new Reply(200, out) + const text = r.raw ?? (r.body === undefined ? '' : JSON.stringify(r.body)) + const ok = r.status < 400 + return { + ok, + status: r.status, + statusText: ok ? 'OK' : 'Error', + text: async () => text, + json: async () => JSON.parse(text) as unknown, + } +} + +/** + * Stub `fetch` with `{ 'GET /api/status': value | (req) => value | Reply }`. + * Lookup is per call, so a test may edit `routes` between steps. A missing + * route is a network failure, which makes an unmocked call visible. + */ +export function stubApi(routes: Record) { + const mock = vi.fn(async (path: string, init?: RequestInit) => { + const method = init?.method ?? 'GET' + const body = init?.body ? (JSON.parse(String(init.body)) as unknown) : undefined + const bare = path.split('?')[0] + const key = [`${method} ${path}`, `${method} ${bare}`].find((k) => k in routes) + if (!key) throw new Error(`unmocked ${method} ${path}`) + return respond(routes[key], { method, path, body }) + }) + vi.stubGlobal('fetch', mock) + return mock +} + +/** Bodies of the calls made to one route, in order. */ +export function bodiesOf(mock: ReturnType, key: string): unknown[] { + return mock.mock.calls + .filter(([p, init]) => `${init?.method ?? 'GET'} ${p}` === key) + .map(([, init]) => (init?.body ? JSON.parse(String(init.body)) : undefined)) +} + +type Listener = (e: { data: string }) => void + +export class FakeEventSource { + static all: FakeEventSource[] = [] + static get last(): FakeEventSource { + return FakeEventSource.all[FakeEventSource.all.length - 1] + } + readonly url: string + closed = false + onopen: (() => void) | null = null + onmessage: Listener | null = null + onerror: (() => void) | null = null + private listeners = new Map() + constructor(url: string) { + this.url = url + FakeEventSource.all.push(this) + } + addEventListener(type: string, fn: Listener) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), fn]) + } + close() { + this.closed = true + } + /** Deliver a named event, as the server's SSE frames do. */ + emit(type: string, payload: unknown) { + const ev = { data: JSON.stringify(payload) } + for (const fn of this.listeners.get(type) ?? []) fn(ev) + } + emitRaw(data: string) { + this.onmessage?.({ data }) + } +} + +export function installEventSource() { + FakeEventSource.all = [] + vi.stubGlobal('EventSource', FakeEventSource) +} + +export const SECTION_PATHS = [ + '/', '/backlog', '/picks', '/gaming', '/library', '/store', '/jobs', '/jobs/$jobId', '/system', '/setup', +] + +/** Render `ui` inside the query client and a memory router at `at`. */ +export function renderRouted(ui: ReactNode, at = '/') { + const root = createRootRoute({ component: () => ui }) + const children = SECTION_PATHS.map((path) => + createRoute({ getParentRoute: () => root, path, component: () => null }), + ) + const router = createRouter({ + routeTree: root.addChildren(children), + history: createMemoryHistory({ initialEntries: [at] }), + }) + const view = render( + + + , + ) + return { router, ...view } +} + +/** Render `ui` inside the query client only (no router needed). */ +export function renderClient(ui: ReactNode) { + return render({ui}) +} diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts index 8be92f5..9a35f63 100644 --- a/web/src/test/setup.ts +++ b/web/src/test/setup.ts @@ -1,8 +1,29 @@ import '@testing-library/jest-dom/vitest' import { cleanup } from '@testing-library/react' import { afterEach } from 'vitest' +import { queryClient } from '../api/queries' + +// The app shares one query client; tests must not see each other's cache, +// and a failing query must fail fast instead of retrying. +queryClient.setDefaultOptions({ + queries: { retry: false, staleTime: 0, refetchOnWindowFocus: false }, +}) + +// jsdom has no modal : model the open state, which is all the app +// and its tests observe (the browser supplies focus trap and Escape). +HTMLDialogElement.prototype.showModal = function showModal() { + this.setAttribute('open', '') +} +HTMLDialogElement.prototype.close = function close() { + this.removeAttribute('open') +} + +// jsdom does no layout, so it has no scrollIntoView either. +Element.prototype.scrollIntoView = () => {} +window.scrollTo = () => {} // React Testing Library does not auto-clean when Vitest globals are disabled. afterEach(() => { cleanup() + queryClient.clear() }) diff --git a/web/src/ui/Dialog.test.tsx b/web/src/ui/Dialog.test.tsx new file mode 100644 index 0000000..a950122 --- /dev/null +++ b/web/src/ui/Dialog.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { Dialog } from './Dialog' + +describe('Dialog', () => { + it('opens modally and focuses the first field', () => { + render( + {}}> + + , + ) + expect(screen.getByRole('dialog', { name: 'Ask' })).toHaveAttribute('open') + expect(screen.getByLabelText('field')).toHaveFocus() + }) + + it('focuses the first action when there is no field', () => { + render( + {}}> + + , + ) + expect(screen.getByRole('button', { name: 'Go' })).toHaveFocus() + }) + + it('renders nothing inside while closed, and closes when open flips', () => { + const { rerender, container } = render( + {}}> +

body

+
, + ) + const el = container.querySelector('dialog')! + expect(el.open).toBe(true) + rerender( + {}}> +

body

+
, + ) + expect(el.open).toBe(false) + expect(screen.queryByText('body')).toBeNull() + }) + + it('does not reopen an already open dialog', () => { + const { rerender, container } = render( + {}}> +

a

+
, + ) + const show = vi.spyOn(container.querySelector('dialog')!, 'showModal') + rerender( + {}} wide> +

b

+
, + ) + expect(show).not.toHaveBeenCalled() + expect(container.querySelector('dialog')).toHaveClass('dialog-wide') + }) + + it('routes Escape and the close button through onClose', async () => { + const onClose = vi.fn() + const { container } = render( + +

x

+
, + ) + const cancel = new Event('cancel', { cancelable: true }) + container.querySelector('dialog')!.dispatchEvent(cancel) + expect(cancel.defaultPrevented).toBe(true) + expect(onClose).toHaveBeenCalledTimes(1) + await userEvent.click(screen.getByRole('button', { name: 'Close' })) + expect(onClose).toHaveBeenCalledTimes(2) + }) + + it('tolerates being a plain class when not wide', () => { + const { container } = render( + {}}> +

x

+
, + ) + expect(container.querySelector('dialog')).toHaveClass('dialog') + expect(container.querySelector('dialog')).not.toHaveClass('dialog-wide') + fireEvent.keyDown(document, { key: 'Escape' }) + }) +}) diff --git a/web/src/ui/Dialog.tsx b/web/src/ui/Dialog.tsx new file mode 100644 index 0000000..0d22af5 --- /dev/null +++ b/web/src/ui/Dialog.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, type ReactNode } from 'react' + +interface Props { + open: boolean + title: string + onClose: () => void + children: ReactNode + /** Wider layout for the job runner. */ + wide?: boolean + labelledBy?: string +} + +/** + * Native `` in modal mode: the browser supplies the focus trap, the + * inert background and Escape (routed through `onClose` so React stays the + * owner of `open`). Focus returns to the opener when it closes. + */ +export function Dialog({ open, title, onClose, children, wide = false }: Props) { + const ref = useRef(null) + + useEffect(() => { + // Effects run after commit, when the below is always mounted. + const el = ref.current as HTMLDialogElement + if (open && !el.open) { + el.showModal() + // Children mounted while the dialog was still closed (display: none), + // so React's autoFocus could not land. The browser then picks the + // first focusable — the close button. Move focus to the first field, + // else the first action, which is what every dialog here wants. + el.querySelector( + 'input:not([disabled]):not([type=hidden]), textarea, select, .dialog-body button:not(.dialog-close):not([disabled])', + )?.focus() + } + if (!open && el.open) el.close() + }, [open]) + + return ( + { + e.preventDefault() + onClose() + }} + > + {open && ( +
+
+

{title}

+ +
+ {children} +
+ )} +
+ ) +} diff --git a/web/src/ui/Notice.test.tsx b/web/src/ui/Notice.test.tsx new file mode 100644 index 0000000..93bbf75 --- /dev/null +++ b/web/src/ui/Notice.test.tsx @@ -0,0 +1,78 @@ +import { act, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiFailure } from '../api/client' +import { CommandLine, CopyButton, Empty, ErrorNotice, Loading, Notice } from './Notice' + +describe('Notice', () => { + it('is a polite status by default and an alert when it is danger', () => { + const { rerender } = render(body) + expect(screen.getByRole('status')).toHaveTextContent('Tbody') + rerender() + expect(screen.getByRole('alert')).toBeInTheDocument() + expect(document.querySelector('.notice-title')).toBeNull() + expect(document.querySelector('.notice-text')).toBeNull() + }) + + it('ErrorNotice shows title, detail, hint and extras', () => { + render( + + extra + , + ) + expect(screen.getByText('Locked')).toBeInTheDocument() + expect(screen.getByText('pick lock')).toBeInTheDocument() + expect(screen.getByText(/forbids this right now/)).toBeInTheDocument() + expect(screen.getByText('extra')).toBeInTheDocument() + }) + + it('ErrorNotice omits an empty detail', () => { + render() + expect(document.querySelectorAll('.notice-text p')).toHaveLength(1) + }) + + it('Loading and Empty render their text', () => { + render( + <> + + nothing + , + ) + expect(screen.getByRole('status')).toHaveTextContent('Loading jobs…') + expect(screen.getByText('nothing')).toBeInTheDocument() + }) +}) + +describe('CopyButton', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('copies, says so, then reverts', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + render() + await userEvent.click(screen.getByRole('button', { name: 'Copy' })) + expect(writeText).toHaveBeenCalledWith('abc') + expect(await screen.findByRole('button', { name: 'Copied' })).toBeInTheDocument() + await act(async () => { + await vi.advanceTimersByTimeAsync(2100) + }) + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument() + }) + + it('does nothing without a clipboard', async () => { + vi.stubGlobal('navigator', {}) + render() + await userEvent.click(screen.getByRole('button', { name: 'Copy it' })) + expect(screen.getByRole('button', { name: 'Copy it' })).toBeInTheDocument() + }) + + it('CommandLine shows the command next to a copy button', () => { + render() + expect(screen.getByText('sudo ./run.sh x')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy' })).toBeInTheDocument() + }) +}) diff --git a/web/src/ui/Notice.tsx b/web/src/ui/Notice.tsx new file mode 100644 index 0000000..913a59c --- /dev/null +++ b/web/src/ui/Notice.tsx @@ -0,0 +1,65 @@ +import { useState, type ReactNode } from 'react' +import { explain } from '../api/errors' + +type Tone = 'info' | 'success' | 'warning' | 'danger' + +export function Notice({ tone = 'info', title, children }: { tone?: Tone; title?: string; children?: ReactNode }) { + return ( +
+ {title && {title}} + {children &&
{children}
} +
+ ) +} + +/** An ApiFailure (or anything thrown) as a titled, actionable message. */ +export function ErrorNotice({ error, children }: { error: unknown; children?: ReactNode }) { + const e = explain(error) + return ( + + {e.detail &&

{e.detail}

} +

{e.hint}

+ {children} +
+ ) +} + +export function CopyButton({ text, label = 'Copy' }: { text: string; label?: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +/** A shell command with a copy button — the recovery path when the UI cannot act. */ +export function CommandLine({ command }: { command: string }) { + return ( +
+ {command} + +
+ ) +} + +export function Loading({ what }: { what: string }) { + return ( +

+

+ ) +} + +export function Empty({ children }: { children: ReactNode }) { + return

{children}

+} diff --git a/web/src/ui/Page.test.tsx b/web/src/ui/Page.test.tsx new file mode 100644 index 0000000..bc5512c --- /dev/null +++ b/web/src/ui/Page.test.tsx @@ -0,0 +1,43 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { Actions, Card, Facts, PageHead } from './Page' + +describe('Page helpers', () => { + it('PageHead renders the title and optional parts', () => { + const { rerender } = render() + expect(screen.getByRole('heading', { name: 'Jobs' })).toBeInTheDocument() + expect(document.querySelector('.sub')).toBeNull() + expect(document.querySelector('.page-actions')).toBeNull() + rerender( + + + , + ) + expect(screen.getByText('Sub text')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Act' })).toBeInTheDocument() + }) + + it('Card is a labelled region, with a head only when it has a title or aside', () => { + const { rerender } = render(content) + expect(screen.getByRole('region', { name: 'Box' })).toHaveTextContent('content') + rerender(aside}>content) + expect(screen.getByText('aside')).toBeInTheDocument() + expect(screen.queryByRole('heading')).toBeNull() + rerender(bare) + expect(document.querySelector('.box-head')).toBeNull() + expect(document.querySelector('.box.x')).not.toBeNull() + }) + + it('Facts lists label/value pairs', () => { + render(two]]} />) + expect(screen.getByText('A').tagName).toBe('DT') + expect(screen.getByText('two').closest('dd')).not.toBeNull() + }) + + it('Actions titles its group', () => { + render(

card

) + expect(screen.getByRole('region', { name: 'Actions' })).toHaveTextContent('card') + render(

c2

) + expect(screen.getByRole('region', { name: 'Custom' })).toBeInTheDocument() + }) +}) diff --git a/web/src/ui/Page.tsx b/web/src/ui/Page.tsx new file mode 100644 index 0000000..3cb7c15 --- /dev/null +++ b/web/src/ui/Page.tsx @@ -0,0 +1,51 @@ +import type { ReactNode } from 'react' + +export function PageHead({ title, sub, children }: { title: string; sub?: ReactNode; children?: ReactNode }) { + return ( +
+
+

{title}

+ {sub &&

{sub}

} +
+ {children &&
{children}
} +
+ ) +} + +export function Card({ title, children, className = '', aside }: { title?: string; children: ReactNode; className?: string; aside?: ReactNode }) { + return ( +
+ {(title || aside) && ( +
+ {title &&

{title}

} + {aside} +
+ )} + {children} +
+ ) +} + +/** Label/value rows; values are right-aligned so they line up across cards. */ +export function Facts({ rows }: { rows: [ReactNode, ReactNode][] }) { + return ( +
+ {rows.map(([k, v], i) => ( +
+
{k}
+
{v}
+
+ ))} +
+ ) +} + +/** Grid of CommandCards. */ +export function Actions({ title = 'Actions', children }: { title?: string; children: ReactNode }) { + return ( +
+

{title}

+
{children}
+
+ ) +} diff --git a/web/src/ui/format.test.ts b/web/src/ui/format.test.ts new file mode 100644 index 0000000..dce8e9a --- /dev/null +++ b/web/src/ui/format.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { ago, bytes, clock, dateTime, duration } from './format' + +describe('ui format', () => { + it('bytes scales units and trims decimals', () => { + expect(bytes(0)).toBe('0 B') + expect(bytes(1536)).toBe('1.5 KB') + expect(bytes(20 * 1024 ** 2)).toBe('20 MB') + expect(bytes(5 * 1024 ** 5)).toBe('5120 TB') + }) + + it('clock renders m:ss and h:mm:ss, never negative', () => { + expect(clock(75)).toBe('1:15') + expect(clock(3725)).toBe('1:02:05') + expect(clock(-4)).toBe('0:00') + }) + + it('ago buckets', () => { + const now = Date.parse('2026-10-10T12:00:00Z') + const at = (s: number) => new Date(now - s * 1000).toISOString() + expect(ago(null, now)).toBe('—') + expect(ago(at(10), now)).toBe('just now') + expect(ago(at(300), now)).toBe('5 min ago') + expect(ago(at(7200), now)).toBe('2 h ago') + expect(ago(at(172_800), now)).toBe('2 d ago') + }) + + it('ago defaults to the current time', () => { + expect(ago(new Date().toISOString())).toBe('just now') + }) + + it('dateTime formats or dashes', () => { + expect(dateTime(null)).toBe('—') + expect(dateTime('2026-10-10T12:00:00Z')).toMatch(/\d/) + }) + + it('duration spans start to end, or to now while running', () => { + expect(duration(null, null)).toBe('—') + expect(duration('2026-10-10T10:00:00Z', '2026-10-10T10:01:05Z')).toBe('1:05') + expect(duration('2026-10-10T10:00:00Z', null, Date.parse('2026-10-10T10:00:30Z'))).toBe('0:30') + expect(duration(new Date().toISOString(), null)).toBe('0:00') + }) +}) diff --git a/web/src/ui/format.ts b/web/src/ui/format.ts new file mode 100644 index 0000000..5cd40bc --- /dev/null +++ b/web/src/ui/format.ts @@ -0,0 +1,45 @@ +// Formatting helpers for the control screens (the planner keeps its own in +// ../format.ts). + +export function bytes(n: number): string { + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let v = n + let i = 0 + while (v >= 1024 && i < units.length - 1) { + v /= 1024 + i += 1 + } + return `${v.toFixed(v >= 10 || i === 0 ? 0 : 1)} ${units[i]}` +} + +/** 75 → "1:15", 3725 → "1:02:05". */ +export function clock(totalSeconds: number): string { + const s = Math.max(0, Math.round(totalSeconds)) + const h = Math.floor(s / 3600) + const m = Math.floor((s % 3600) / 60) + const sec = String(s % 60).padStart(2, '0') + return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}` +} + +/** Short relative time: "just now", "5 min ago", "3 h ago", "2 d ago". */ +export function ago(iso: string | null, now: number = Date.now()): string { + if (!iso) return '—' + const s = Math.round((now - Date.parse(iso)) / 1000) + if (s < 45) return 'just now' + if (s < 3600) return `${Math.round(s / 60)} min ago` + if (s < 86_400) return `${Math.round(s / 3600)} h ago` + return `${Math.round(s / 86_400)} d ago` +} + +export function dateTime(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleString(undefined, { + month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', + }) +} + +export function duration(startIso: string | null, endIso: string | null, now: number = Date.now()): string { + if (!startIso) return '—' + const end = endIso ? Date.parse(endIso) : now + return clock((end - Date.parse(startIso)) / 1000) +} diff --git a/web/src/ui/useNow.test.tsx b/web/src/ui/useNow.test.tsx new file mode 100644 index 0000000..60291a7 --- /dev/null +++ b/web/src/ui/useNow.test.tsx @@ -0,0 +1,26 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useNow } from './useNow' + +describe('useNow', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(1_000_000) + }) + afterEach(() => vi.useRealTimers()) + + it('ticks at the requested rate and stops on unmount', () => { + const { result, unmount } = renderHook(() => useNow(500)) + expect(result.current).toBe(1_000_000) + act(() => vi.advanceTimersByTime(500)) + expect(result.current).toBe(1_000_500) + unmount() + expect(vi.getTimerCount()).toBe(0) + }) + + it('defaults to one second', () => { + const { result } = renderHook(() => useNow()) + act(() => vi.advanceTimersByTime(1000)) + expect(result.current).toBe(1_001_000) + }) +}) diff --git a/web/src/ui/useNow.ts b/web/src/ui/useNow.ts new file mode 100644 index 0000000..27f336d --- /dev/null +++ b/web/src/ui/useNow.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from 'react' + +/** Current time, re-rendering every `ms` — for countdowns and "x ago" labels. */ +export function useNow(ms = 1000): number { + const [now, setNow] = useState(() => Date.now()) + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), ms) + return () => clearInterval(id) + }, [ms]) + return now +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json index dfadb0b..f10e723 100644 --- a/web/tsconfig.node.json +++ b/web/tsconfig.node.json @@ -18,5 +18,5 @@ "erasableSyntaxOnly": true, "noFallthroughCasesInSwitch": true }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "mock"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index d960435..5c9cc72 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,34 +1,96 @@ -import react from '@vitejs/plugin-react' -import { defineConfig } from 'vitest/config' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import babel from '@rolldown/plugin-babel' +import react, { reactCompilerPreset } from '@vitejs/plugin-react' +import type { Plugin, PluginOption } from 'vite' +import { defineConfig, type ViteUserConfig } from 'vitest/config' +import { TOKEN_META } from './src/api/contract.ts' -// The Python API (`./run.sh serve`) runs on 127.0.0.1:8000. In dev, Vite -// serves the UI and proxies `/api/*` to it, so there are no CORS concerns. -// In production the Python server serves the built bundle from `web/dist`. -export default defineConfig({ - plugins: [react()], - server: { - proxy: { - '/api': 'http://127.0.0.1:8000', +// The Python API (`./run.sh serve`) runs on 127.0.0.1:8000 (`SBE_PORT` to +// point dev at another one). In dev, Vite serves the UI and proxies `/api/*` +// to it, so there are no CORS concerns. The proxy presents itself as the +// API's own origin (Host + Origin) so the server's DNS-rebinding and origin +// checks pass, and `serverToken()` puts that server's per-launch token into +// index.html the way the server does for the built bundle. In production +// the Python server serves the built bundle from `web/dist`. +// +// `--mode mock` (npm run dev:mock) swaps the proxy for an in-memory mock of +// the whole contract (web/mock/). It is imported only in that mode and is a +// serve-only plugin, so none of it can reach `vite build` output. +// +// React Compiler runs through Babel: plugin-react 6's native (Oxc) compiler +// is experimental and its `oxc-transform-react` peer range trails the newest +// stable release. +const apiPort = process.env.SBE_PORT ?? '8000' +const apiOrigin = `http://127.0.0.1:${apiPort}` + +/** + * Dev only (`apply: 'serve'`): inject `` from the + * API server's token file, re-read on every page load so a restarted server's + * new token is picked up by a reload. Never part of `vite build`. + */ +function serverToken(): Plugin { + const runtime = process.env.XDG_RUNTIME_DIR ?? `/run/user/${process.getuid?.() ?? 0}` + const file = join(runtime, 'steam-backlog-enforcer', `web-${apiPort}.token`) + return { + name: 'sbe-server-token', + apply: 'serve', + transformIndexHtml(html) { + let token: string + try { + token = readFileSync(file, 'utf8').trim() + } catch { + console.warn(`[sbe] no token at ${file}: is ./run.sh serve running on port ${apiPort}? Mutating requests will fail.`) + return html + } + return [{ tag: 'meta', attrs: { name: TOKEN_META, content: token }, injectTo: 'head-prepend' }] }, - }, - test: { - environment: 'jsdom', - setupFiles: './src/test/setup.ts', - coverage: { - provider: 'v8', - include: ['src/**/*.{ts,tsx}'], - exclude: [ - 'src/**/*.test.{ts,tsx}', - 'src/test/**', - 'src/main.tsx', - 'src/vite-env.d.ts', - ], - thresholds: { - statements: 100, - branches: 100, - functions: 100, - lines: 100, + } +} + +export default defineConfig(async ({ mode }): Promise => { + // Not under vitest: the Compiler's generated memo-cache branches (e.g. the + // "cache hit" arm for a closure that is rebuilt every render) are counted by + // v8 coverage but can never run, so 100 % branch coverage of the *source* + // would be unreachable. The compiled output is still built and linted + // (`eslint-plugin-react-hooks` carries the Compiler's rules). + const plugins: PluginOption[] = [react()] + if (mode !== 'test') plugins.push(babel({ presets: [reactCompilerPreset()] })) + if (mode === 'mock') { + const { mockApi } = await import('./mock/plugin.ts') + plugins.push(mockApi()) + } else { + plugins.push(serverToken()) + } + return { + plugins, + server: + mode === 'mock' + ? {} + : { + proxy: { + '/api': { target: apiOrigin, changeOrigin: true, headers: { Origin: apiOrigin } }, + }, + }, + test: { + environment: 'jsdom', + setupFiles: './src/test/setup.ts', + coverage: { + provider: 'v8', + include: ['src/**/*.{ts,tsx}'], + exclude: [ + 'src/**/*.test.{ts,tsx}', + 'src/test/**', + 'src/main.tsx', + 'src/vite-env.d.ts', + ], + thresholds: { + statements: 100, + branches: 100, + functions: 100, + lines: 100, + }, }, }, - }, + } })