From 65536347687e90a3ed18c16b4843edb28dab47c1 Mon Sep 17 00:00:00 2001 From: Bill Grant Date: Fri, 16 Oct 2026 14:40:34 -0400 Subject: [PATCH 1/3] update logic fix attempt for linux --- tests/contracts/test_platform_services.py | 51 +++++++++++- tests/test_meter.py | 98 +++++++++++++++++++++++ token_meter/app.py | 30 ++++++- token_meter/platforms/base.py | 1 + token_meter/platforms/common.py | 2 +- token_meter/platforms/linux.py | 28 ++++++- token_meter/platforms/unsupported.py | 2 +- token_meter/platforms/windows.py | 2 +- 8 files changed, 204 insertions(+), 10 deletions(-) diff --git a/tests/contracts/test_platform_services.py b/tests/contracts/test_platform_services.py index d1614b0..9e5fe5c 100644 --- a/tests/contracts/test_platform_services.py +++ b/tests/contracts/test_platform_services.py @@ -142,23 +142,66 @@ def test_windows_process_trash_and_update_launch_are_owned_platform_decisions(se ) def test_posix_update_plan_preserves_existing_helper_contract(self): - platform = platform_services("linux", environment={}, home="/home/example") + platform = platform_services("darwin", environment={}, home="/Users/example") update = platform.update_plan( - "/opt/token-meter", "/home/example/source", "/home/example/status.json" + "/opt/token-meter", "/Users/example/source", "/Users/example/status.json" ) self.assertEqual(update.script_path, "/opt/token-meter/scripts/update") self.assertEqual(update.command, ( "/opt/token-meter/scripts/update", - "/home/example/source", - "/home/example/status.json", + "/Users/example/source", + "/Users/example/status.json", )) self.assertEqual( platform.agent_launcher("/opt/token-meter"), "/opt/token-meter/scripts/run-token-meter-mcp", ) + def test_linux_update_plan_runs_the_helper_outside_the_server_cgroup(self): + platform = platform_services("linux", environment={}, home="/home/example") + + update = platform.update_plan( + "/opt/token-meter", "/home/example/source", "/home/example/status.json", + systemd_run="/usr/bin/systemd-run", + ) + retry = platform.update_plan( + "/opt/token-meter", "/home/example/source", "/home/example/status.json", + retry_revision="b" * 40, systemd_run="/usr/bin/systemd-run", + ) + + self.assertTrue(update.supported) + self.assertEqual(update.script_path, "/opt/token-meter/scripts/update") + self.assertEqual(update.command, ( + "/usr/bin/systemd-run", "--user", "--quiet", "--collect", + "--unit=token-meter-update", "--description=Token Meter update", "--", + "/opt/token-meter/scripts/update", + "/home/example/source", + "/home/example/status.json", + )) + self.assertIn( + "--setenv=TOKEN_METER_UPDATE_RETRY_REVISION=" + "b" * 40, retry.command, + ) + self.assertLess( + retry.command.index("--setenv=TOKEN_METER_UPDATE_RETRY_REVISION=" + "b" * 40), + retry.command.index("--"), + ) + + def test_linux_update_plan_falls_back_to_the_helper_without_systemd_run(self): + platform = platform_services("linux", environment={}, home="/home/example") + + update = platform.update_plan( + "/opt/token-meter", "/home/example/source", "/home/example/status.json", + systemd_run="", + ) + + self.assertEqual(update.command, ( + "/opt/token-meter/scripts/update", + "/home/example/source", + "/home/example/status.json", + )) + def test_linux_prefers_gio_and_macos_uses_owned_trash_directory(self): linux = platform_services("linux", environment={}, home="/home/example") macos = platform_services("darwin", environment={}, home="/Users/example") diff --git a/tests/test_meter.py b/tests/test_meter.py index 180ee1d..09de979 100644 --- a/tests/test_meter.py +++ b/tests/test_meter.py @@ -15992,7 +15992,9 @@ def test_explicit_install_starts_only_the_bounded_detached_helper(self): "dirty": False, }, str(status_path)) popen = mock.Mock() + macos = meter.platform_services("darwin", environment={}, home=tmp) with (mock.patch.object(meter, "source_checkout_path", return_value=tmp), + mock.patch.object(meter, "_PLATFORM_SERVICES", macos), mock.patch.object(meter.os.path, "isfile", return_value=True), mock.patch.object(meter.os, "access", return_value=True)): result = meter.start_software_update( @@ -16009,6 +16011,102 @@ def test_explicit_install_starts_only_the_bounded_detached_helper(self): self.assertTrue(kwargs["close_fds"]) self.assertNotIn(tmp, json.dumps(result)) + def test_linux_install_launches_the_helper_as_a_transient_user_unit(self): + with tempfile.TemporaryDirectory() as tmp: + settings_path = self.enabled_settings(tmp) + status_path = Path(tmp) / "update-status.json" + target = "b" * 40 + meter._persist_update_status({ + "phase": "failed", + "error_code": "update_interrupted", + "current_revision": "a" * 40, + "latest_revision": target, + "failed_revision": target, + "checked_at": 1234, + "available": True, + "can_update": False, + }, str(status_path)) + popen = mock.Mock() + linux = meter.platform_services("linux", environment={}, home=tmp) + with (mock.patch.object(meter, "source_checkout_path", return_value=tmp), + mock.patch.object(meter, "_PLATFORM_SERVICES", linux), + mock.patch("token_meter.platforms.linux.shutil.which", + return_value="/usr/bin/systemd-run"), + mock.patch.object(meter.os.path, "isfile", return_value=True), + mock.patch.object(meter.os, "access", return_value=True)): + result = meter.start_software_update( + popen=popen, + settings_path=str(settings_path), + status_path=str(status_path), + ) + command = popen.call_args.args[0] + self.assertTrue(result["ok"]) + self.assertEqual(command[:2], ["/usr/bin/systemd-run", "--user"]) + self.assertIn("--setenv=TOKEN_METER_UPDATE_RETRY_REVISION=" + target, command) + separator = command.index("--") + self.assertTrue(command[separator + 1].endswith("/scripts/update")) + self.assertEqual(command[separator + 2:], [tmp, str(status_path)]) + + def test_stale_in_progress_update_becomes_a_retryable_interruption(self): + with tempfile.TemporaryDirectory() as tmp: + settings_path = self.enabled_settings(tmp) + status_path = Path(tmp) / "update-status.json" + target = "b" * 40 + record = { + "phase": "installing", + "current_revision": target, + "latest_revision": target, + "previous_revision": "a" * 40, + "checked_at": 1000, + "started_at": 1001, + "available": True, + "can_update": False, + } + meter._persist_update_status(record, str(status_path)) + fresh = meter._recover_stale_update( + record, now=1001 + meter.UPDATE_STALE_AFTER_S - 1, + status_path=str(status_path), + ) + still_installing = json.loads(status_path.read_text())["phase"] + recovered = meter._recover_stale_update( + record, now=1001 + meter.UPDATE_STALE_AFTER_S, + status_path=str(status_path), + ) + stored = json.loads(status_path.read_text()) + status = meter.software_update_status( + settings_path=str(settings_path), status_path=str(status_path), + ) + self.assertFalse(fresh) + self.assertEqual(still_installing, "installing") + self.assertTrue(recovered) + self.assertEqual(stored["phase"], "failed") + self.assertEqual(stored["error_code"], "update_interrupted") + self.assertEqual(stored["failed_revision"], target) + self.assertEqual(stored["previous_revision"], "a" * 40) + self.assertEqual(status["state"], "attention") + self.assertIn("stopped before it finished", status["message"]) + + def test_update_watcher_recovers_a_stale_in_progress_update(self): + wake = mock.Mock() + wake.wait.side_effect = [RuntimeError("stop watcher")] + stale = {"phase": "installing", "started_at": 1} + with (mock.patch.object(meter, "_update_wake", wake), + mock.patch.object( + meter, "update_settings", + return_value={"enabled": True, "auto_install": False}, + ), + mock.patch.object( + meter, "_update_status_record", + side_effect=[stale, {"phase": "failed"}], + ), + mock.patch.object(meter, "_persist_update_status", return_value=True) as persist, + mock.patch.object(meter, "check_for_software_update") as check): + with self.assertRaisesRegex(RuntimeError, "stop watcher"): + meter.software_update_watcher() + self.assertEqual(persist.call_args.args[0]["error_code"], "update_interrupted") + self.assertEqual(wake.wait.call_args_list, [mock.call(meter.UPDATE_CHECK_INTERVAL_S)]) + check.assert_not_called() + def test_explicit_install_retries_a_failed_current_revision(self): with tempfile.TemporaryDirectory() as tmp: settings_path = self.enabled_settings(tmp) diff --git a/token_meter/app.py b/token_meter/app.py index 7977c84..9526e27 100644 --- a/token_meter/app.py +++ b/token_meter/app.py @@ -365,6 +365,8 @@ def hermes_state_db_path(environ=None): UPDATE_CHECK_INTERVAL_S = 10 * 60 GIT_DELIVERY_INTERVAL_S = 5 * 60 UPDATE_FETCH_TIMEOUT_S = 45 +# Comfortably past the installer's default 600-second readiness wait. +UPDATE_STALE_AFTER_S = 30 * 60 UPDATE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{1,199}$") # Compatibility re-exports retain the existing runtime-oriented names while @@ -2243,6 +2245,7 @@ def _update_message(state, error_code="", latest_revision=""): "Reinstall Token Meter to repair automatic updates." ), "install_failed": "The update did not install. The existing checkout needs attention.", + "update_interrupted": "The last update stopped before it finished. Try installing it again.", } return messages.get(error_code, "Update status is unavailable.") @@ -2462,7 +2465,8 @@ def start_software_update(popen=None, settings_path=None, status_path=None): failed_target = _safe_update_revision(raw.get("failed_revision")) retry_failed = bool( str(raw.get("phase") or "") == "failed" - and str(raw.get("error_code") or "") in {"install_failed", "launch_failed"} + and str(raw.get("error_code") or "") in { + "install_failed", "launch_failed", "update_interrupted"} and failed_target and failed_target == _safe_update_revision(raw.get("latest_revision")) ) @@ -2472,6 +2476,7 @@ def start_software_update(popen=None, settings_path=None, status_path=None): target_status_path = status_path or TOKEN_METER_UPDATE_STATUS update_plan = _PLATFORM_SERVICES.update_plan( _SOURCE_ROOT, checkout or "", target_status_path, + retry_revision=failed_target if retry_failed else "", ) helper_ready = bool( update_plan.supported @@ -2536,6 +2541,26 @@ def start_software_update(popen=None, settings_path=None, status_path=None): return {"ok": True, "status": software_update_status(settings_path, status_path)} +def _recover_stale_update(previous, now=None, status_path=None): + """Fail an in-progress update whose helper stopped without a final status.""" + started_at = _safe_update_int(previous.get("started_at")) + now = int(now or time.time()) + if started_at and now - started_at < UPDATE_STALE_AFTER_S: + return False + latest_revision = previous.get("latest_revision", "") + return _persist_update_status({ + "phase": "failed", + "error_code": "update_interrupted", + "checked_at": _safe_update_int(previous.get("checked_at")), + "started_at": started_at, + "current_revision": previous.get("current_revision", ""), + "latest_revision": latest_revision, + "failed_revision": latest_revision, + "available": True, + "can_update": False, + }, status_path) + + def software_update_watcher(): """Run checks every 10 minutes and install safe results when enabled.""" while True: @@ -2547,7 +2572,8 @@ def software_update_watcher(): previous = _update_status_record() phase = str(previous.get("phase") or "") if phase in {"starting", "fetching", "installing"}: - _update_wake.wait(5) + if not _recover_stale_update(previous): + _update_wake.wait(5) continue if phase in {"complete", "failed"}: if _update_wake.wait(UPDATE_CHECK_INTERVAL_S): diff --git a/token_meter/platforms/base.py b/token_meter/platforms/base.py index 34e865a..3c3c398 100644 --- a/token_meter/platforms/base.py +++ b/token_meter/platforms/base.py @@ -89,6 +89,7 @@ def trash_plan( def update_plan( self, source_root: str, checkout: str, status_path: str, + retry_revision: str = "", ) -> UpdatePlan: ... diff --git a/token_meter/platforms/common.py b/token_meter/platforms/common.py index eb488e5..ff684df 100644 --- a/token_meter/platforms/common.py +++ b/token_meter/platforms/common.py @@ -41,7 +41,7 @@ def process_options(self, purpose): def capabilities(self): return PlatformCapabilities(paths=True, detached_process=True, trash=True) - def update_plan(self, source_root, checkout, status_path): + def update_plan(self, source_root, checkout, status_path, retry_revision=""): script = os.path.join(str(source_root), "scripts", "update") return UpdatePlan( supported=True, diff --git a/token_meter/platforms/linux.py b/token_meter/platforms/linux.py index 3f158b7..77aabc5 100644 --- a/token_meter/platforms/linux.py +++ b/token_meter/platforms/linux.py @@ -1,14 +1,16 @@ """Linux/XDG path and lifecycle policy.""" import os +import shutil -from .base import CpuActivity, PlatformPaths, TrashPlan +from .base import CpuActivity, PlatformPaths, TrashPlan, UpdatePlan from .common import PosixPlatformServices, expand_home POWER_SUPPLY_ROOT = "/sys/class/power_supply" POWER_SUPPLY_MAX_ENTRIES = 32 PROC_STAT_PATH = "/proc/stat" +UPDATE_UNIT_NAME = "token-meter-update" def parse_proc_stat(text): @@ -84,6 +86,30 @@ def trash_plan(self, path, override=None, command_available=False): destination_label="Trash", ) + def update_plan(self, source_root, checkout, status_path, retry_revision="", + systemd_run=None): + plan = super().update_plan(source_root, checkout, status_path, retry_revision) + if systemd_run is None: + systemd_run = shutil.which("systemd-run", path=self.environment.get("PATH")) + if not systemd_run: + return plan + # systemd tracks the server's cgroup, not its session: a setsid child is + # still stopped with the server when the installer restarts it. A + # transient user unit survives that restart, but starts from the user + # manager's environment rather than this process's. + command = [ + systemd_run, "--user", "--quiet", "--collect", + f"--unit={UPDATE_UNIT_NAME}", + "--description=Token Meter update", + ] + if retry_revision: + command.append(f"--setenv=TOKEN_METER_UPDATE_RETRY_REVISION={retry_revision}") + command.append("--") + command.extend(plan.command) + return UpdatePlan( + supported=True, script_path=plan.script_path, command=tuple(command), + ) + def cpu_activity(self, path=PROC_STAT_PATH): try: with open(path, encoding="utf-8") as handle: diff --git a/token_meter/platforms/unsupported.py b/token_meter/platforms/unsupported.py index 88f887a..f9cb294 100644 --- a/token_meter/platforms/unsupported.py +++ b/token_meter/platforms/unsupported.py @@ -24,7 +24,7 @@ def trash_plan(self, path, override=None, command_available=False): message="Moving session logs to Trash is unavailable on this platform.", ) - def update_plan(self, source_root, checkout, status_path): + def update_plan(self, source_root, checkout, status_path, retry_revision=""): return UpdatePlan( supported=False, error_code="unsupported_platform", diff --git a/token_meter/platforms/windows.py b/token_meter/platforms/windows.py index 929a56f..acf0f36 100644 --- a/token_meter/platforms/windows.py +++ b/token_meter/platforms/windows.py @@ -93,7 +93,7 @@ def trash_plan(self, path, override=None, command_available=False): destination_label="Trash", ) - def update_plan(self, source_root, checkout, status_path): + def update_plan(self, source_root, checkout, status_path, retry_revision=""): script = ntpath.join(str(source_root), "scripts", "update-windows.ps1") return UpdatePlan( supported=True, From d260d8d4f486169f3b78f94f313772d1f4c03177 Mon Sep 17 00:00:00 2001 From: Pratik Bhavsar Date: Tue, 20 Oct 2026 09:46:27 +0530 Subject: [PATCH 2/3] ci: run tests on every host, check macOS install and Linux updates Unit and contract suites now run on Linux, macOS, and Windows with static and native-build gates. Install checks add a macOS install and a Linux end-to-end update that restarts the server mid-install. Co-Authored-By: Claude Code --- .github/workflows/install-checks.yml | 201 ++++++++++++++++++++++++++- .github/workflows/tests.yml | 84 +++++++++++ 2 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/install-checks.yml b/.github/workflows/install-checks.yml index 6fdeb68..7db2b9e 100644 --- a/.github/workflows/install-checks.yml +++ b/.github/workflows/install-checks.yml @@ -1,8 +1,9 @@ name: Install checks -# Installs the backend on clean Linux and Windows hosts, checks the running -# service, then uninstalls. Tray companions need a desktop session that hosted -# runners do not reliably provide, so they are not covered here. +# Installs the backend on clean macOS, Linux, and Windows hosts, checks the +# running service, then uninstalls; on Linux it also runs a real update. Tray +# companions need a desktop session that hosted runners do not reliably +# provide, so they are not covered here. on: push: branches: [main] @@ -95,6 +96,200 @@ jobs: if: failure() run: journalctl --user -u token-meter-server.service -n 200 --no-pager || true + # Installs from a local clone whose origin is a local bare remote, publishes a + # newer commit there, then drives Install through the dashboard API. The + # installer restarts the server mid-update, so this proves the helper + # outlives that restart and the server comes back on the new revision. + linux-update: + name: Linux update + runs-on: ubuntu-latest + timeout-minutes: 25 + env: + TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Start a systemd user session + run: | + sudo loginctl enable-linger "$USER" + runtime_dir="/run/user/$(id -u)" + export XDG_RUNTIME_DIR="$runtime_dir" + export DBUS_SESSION_BUS_ADDRESS="unix:path=$runtime_dir/bus" + for _ in $(seq 1 30); do + systemctl --user show-environment >/dev/null 2>&1 && break + sleep 1 + done + systemctl --user show-environment >/dev/null + echo "XDG_RUNTIME_DIR=$XDG_RUNTIME_DIR" >> "$GITHUB_ENV" + echo "DBUS_SESSION_BUS_ADDRESS=$DBUS_SESSION_BUS_ADDRESS" >> "$GITHUB_ENV" + + - name: Publish this revision to a local remote + run: | + remote="$RUNNER_TEMP/update-remote.git" + source="$RUNNER_TEMP/update-source" + git init --quiet --bare --initial-branch=main "$remote" + git push --quiet "$remote" HEAD:refs/heads/main + git clone --quiet "$remote" "$source" + echo "UPDATE_SOURCE=$source" >> "$GITHUB_ENV" + + - name: Add a sample session and turn off automatic installs + run: | + python3 .github/scripts/write_sample_trace.py + mkdir -p "$HOME/.token-meter" + printf '%s\n' '{"updates": {"enabled": true, "auto_install": false}}' \ + > "$HOME/.token-meter/settings.json" + + - name: Install + run: | + cd "$UPDATE_SOURCE" + ./scripts/install --backend-only + systemctl --user show -p MainPID --value token-meter-server.service \ + > "$RUNNER_TEMP/server-pid-before" + + - name: Publish a newer revision + run: | + cd "$UPDATE_SOURCE" + git -c user.name="Token Meter CI" -c user.email="ci@token-meter.invalid" \ + commit --quiet --allow-empty -m "ci: update bump" + git push --quiet origin main + echo "UPDATE_TARGET=$(git rev-parse HEAD)" >> "$GITHUB_ENV" + + - name: Check for and install the update + run: | + python3 - <<'PY' + import json, os, time, urllib.request + base = "http://127.0.0.1:8722" + target = os.environ["UPDATE_TARGET"] + + def get(path): + with urllib.request.urlopen(base + path, timeout=10) as response: + return json.load(response) + + def post(path, token): + request = urllib.request.Request( + base + path, data=b"{}", method="POST", + headers={"Content-Type": "application/json", "X-Token-Meter-Action": token}, + ) + with urllib.request.urlopen(request, timeout=10) as response: + return response.status, json.load(response) + + token = get("/updates/status")["actions"]["token"] + deadline = time.monotonic() + 180 + # A check already running from server startup ignores a new request, + # so keep asking until one sees the published revision. + while True: + status = get("/updates/status") + if status.get("can_update") and target.startswith(status.get("latest_revision") or "-"): + break + assert time.monotonic() < deadline, status + if not status.get("checking"): + post("/updates/check", token) + time.sleep(2) + code, result = post("/updates/install", token) + assert code == 202 and result.get("ok"), (code, result) + print("update started for", target[:12]) + PY + + - name: Wait for the update to finish + run: | + status_file="$HOME/.token-meter/update-status.json" + deadline=$((SECONDS + 720)) + while (( SECONDS < deadline )); do + phase="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("phase",""))' "$status_file" 2>/dev/null || true)" + case "$phase" in + complete) echo "update complete"; break ;; + failed) cat "$status_file"; exit 1 ;; + esac + sleep 2 + done + test "$phase" = complete || { echo "update stuck in phase: $phase" >&2; cat "$status_file"; exit 1; } + + - name: Check the updated installation + run: | + runtime="${XDG_DATA_HOME:-$HOME/.local/share}/token-meter/runtime" + installed="$(cat "$runtime/INSTALLED_REVISION")" + case "$UPDATE_TARGET" in + "$installed"*) echo "installed revision ok: $installed" ;; + *) echo "installed $installed, expected $UPDATE_TARGET" >&2; exit 1 ;; + esac + systemctl --user is-active --quiet token-meter-server.service + before="$(cat "$RUNNER_TEMP/server-pid-before")" + after="$(systemctl --user show -p MainPID --value token-meter-server.service)" + test "$before" != "$after" || { echo "the server was not restarted" >&2; exit 1; } + curl -fsS --max-time 10 http://127.0.0.1:8722/health | python3 -c 'import json, sys; h = json.load(sys.stdin); assert h.get("ok"), h; print("health ok")' + + - name: Show update and server logs + if: failure() + run: | + cat "$HOME/.token-meter/update-status.json" || true + journalctl --user -u token-meter-update.service -n 200 --no-pager || true + journalctl --user -u token-meter-server.service -n 200 --no-pager || true + + macos: + name: macOS install + runs-on: macos-latest + timeout-minutes: 20 + env: + TOKEN_METER_READINESS_TIMEOUT_SECONDS: "600" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - name: Add a sample session + run: python3 .github/scripts/write_sample_trace.py + + - name: Install + run: ./scripts/install --backend-only + + - name: Check the installation + run: | + runtime="$HOME/Library/Application Support/Token Meter/runtime" + launchctl print "gui/$UID/com.token-meter.server" >/dev/null + test "$(cat "$runtime/INSTALL_MODE")" = "backend-only" + python3 - <<'PY' + import json, time, urllib.request + deadline = time.monotonic() + 60 + while True: + with urllib.request.urlopen("http://127.0.0.1:8722/health", timeout=10) as response: + health = json.load(response) + if health.get("inventory_ready") or time.monotonic() > deadline: + break + time.sleep(1) + assert health.get("ok") and health.get("state_ready"), health + assert health.get("inventory_ready"), health + assert (health.get("sources") or 0) >= 1, health + assert not health.get("runtime_adapter_failures"), health + print("health ok:", health["sources"], "session(s) indexed") + PY + curl -fsS --max-time 10 http://127.0.0.1:8722/menubar | python3 -c 'import json, sys; json.load(sys.stdin); print("menubar ok")' + + - name: Uninstall + run: | + runtime="$HOME/Library/Application Support/Token Meter/runtime" + "$runtime/scripts/uninstall-launch-agent" + if launchctl print "gui/$UID/com.token-meter.server" >/dev/null 2>&1; then + echo "com.token-meter.server is still loaded" >&2 + exit 1 + fi + for _ in $(seq 1 10); do + curl -fsS --max-time 3 http://127.0.0.1:8722/health >/dev/null 2>&1 || { echo "uninstall ok"; exit 0; } + sleep 1 + done + echo "the server still answers after uninstall" >&2 + exit 1 + + - name: Show server logs + if: failure() + run: | + runtime="$HOME/Library/Application Support/Token Meter/runtime" + for name in meter.err.log meter.log; do + [[ -f "$runtime/$name" ]] && { echo "== $name"; tail -n 200 "$runtime/$name"; } + done + true + windows: name: Windows install runs-on: windows-latest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..42b63c4 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,84 @@ +name: Tests + +# Runs the unit and contract suites on every supported host, so a change made +# for one platform is checked on the others, plus the static gates from +# specs/AGENTS.md and the native companion build. +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: tests-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + unit: + name: Unit tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + env: + PYTHONUTF8: "1" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Run the test suite + run: python -m unittest discover -s tests + + static: + name: Static checks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + - name: Compile Python + run: | + PYTHONPYCACHEPREFIX="$RUNNER_TEMP/pycache" python3 -m py_compile \ + meter.py token_meter_mcp.py $(find token_meter -type f -name '*.py' -print) + + - name: Parse the dashboard JavaScript + run: | + node -e "const fs=require('fs');const h=fs.readFileSync('page.html','utf8');const m=h.match(/