diff --git a/.github/workflows/install-checks.yml b/.github/workflows/install-checks.yml index 6fdeb68a..7db2b9e8 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 00000000..7696eacb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,129 @@ +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" + + # Pull-request checkouts are detached; installer tests clone this + # repository and expect a local main branch, as a developer clone has. + - name: Name the checked-out commit main + run: git switch --quiet -C main + + - name: Run the test suite + if: runner.os != 'Windows' + run: python -m unittest discover -s tests + + # The full suite is not yet Windows-clean (SQLite handles held across + # temp-dir cleanup, mktime range limits). Run every module that is, so + # a regression in them still fails here. + - name: Run the Windows-clean test modules + if: runner.os == 'Windows' + run: >- + python -m unittest + tests.contracts.test_agent_workflow tests.contracts.test_architecture_guards + tests.contracts.test_compat tests.contracts.test_contracts tests.contracts.test_design_system + tests.contracts.test_kiro_extension_budget tests.contracts.test_model_pricing + tests.contracts.test_path_cache tests.contracts.test_platform_services + tests.contracts.test_public_projections tests.contracts.test_quota_privacy + tests.contracts.test_quota_registry tests.contracts.test_runtime_catalog + tests.contracts.test_runtime_manifest tests.contracts.test_runtime_registry + tests.contracts.test_telemetry_mapping tests.contracts.test_windows_packaging + tests.domain.test_aggregates tests.domain.test_compare tests.domain.test_matched_pace_parity + tests.domain.test_timing_tools_insights tests.domain.test_usage + tests.integration.test_application_composition tests.integration.test_discovery_performance + tests.integration.test_model_pricing_compat + tests.integration.test_runtime_dispatch_characterization + tests.integration.test_session_selection + tests.runtimes.test_claude_adapter tests.runtimes.test_codex_adapter + tests.runtimes.test_kiro_adapter tests.runtimes.test_pi_adapter + tests.test_claude_cost_correctness tests.test_codex_model_identity + tests.test_current_model_catalog tests.test_goals tests.test_installer_modes tests.test_linux_update + tests.test_mcp_queries tests.test_mcp_server tests.test_menubar_settings + + 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(/