Add native Windows host daemon (bleak/WinRT)
Adds a Windows port of the host daemon so the Clawdmeter stays connected on Windows independent of WSL. Mirrors the macOS daemon and speaks the existing GATT data service unchanged — no firmware changes. - claude_usage_daemon_windows.py: Windows-local OAuth token read + Anthropic poll + BLE scan/connect/write, with auto-reconnect (connect-retry wrapper, zombie-link break, split fast/slow backoff) - tray_windows.py: pystray login-startup tray app (status icon + Quit) - autostart_windows.py: winreg HKCU\Run autostart via pythonw.exe - icon_assets.py: per-state tray icons composited from logo.h - install-windows.ps1 + daemon/README-windows.md: turnkey setup - pytest suite: token / poll / reconnect / tray / autostart / no-WSL guard
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for daemon/claude_usage_daemon_windows.py — TOKEN-01.
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_token.py -x -q
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.claude_usage_daemon_windows import _extract_access_token, read_token, _windows_credential_candidates, _read_expiry
|
||||
|
||||
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def test_extract_nested_shape():
|
||||
"""_extract_access_token handles the real Windows claudeAiOauth nested shape."""
|
||||
blob = (FIXTURES / "credentials_nested.json").read_text()
|
||||
assert _extract_access_token(blob) == "sk-ant-test-1234"
|
||||
|
||||
|
||||
def test_extract_direct_shape():
|
||||
"""_extract_access_token handles the legacy direct accessToken shape."""
|
||||
blob = (FIXTURES / "credentials_direct.json").read_text()
|
||||
assert _extract_access_token(blob) == "sk-ant-test-5678"
|
||||
|
||||
|
||||
def test_read_token_env_override(tmp_path, monkeypatch):
|
||||
"""read_token() honours CLAUDE_CREDENTIALS_PATH env override (D-03)."""
|
||||
creds = tmp_path / ".credentials.json"
|
||||
creds.write_text(json.dumps({"accessToken": "sk-ant-test-ENV"}))
|
||||
monkeypatch.setenv("CLAUDE_CREDENTIALS_PATH", str(creds))
|
||||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||||
assert read_token() == "sk-ant-test-ENV"
|
||||
|
||||
|
||||
def test_read_token_primary_path(tmp_path, monkeypatch):
|
||||
"""read_token() reads from the primary candidate path (first hit wins)."""
|
||||
creds = tmp_path / ".claude" / ".credentials.json"
|
||||
creds.parent.mkdir(parents=True)
|
||||
creds.write_text(json.dumps({"claudeAiOauth": {"accessToken": "sk-ant-test-PRIMARY"}}))
|
||||
monkeypatch.delenv("CLAUDE_CREDENTIALS_PATH", raising=False)
|
||||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||||
# Monkeypatch _windows_credential_candidates to return only our tmp path
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
monkeypatch.setattr(mod, "_windows_credential_candidates", lambda: [creds])
|
||||
assert read_token() == "sk-ant-test-PRIMARY"
|
||||
|
||||
|
||||
def test_read_token_localappdata_fallback(tmp_path, monkeypatch):
|
||||
"""read_token() falls back to %LOCALAPPDATA%/Claude/.credentials.json when primary is absent."""
|
||||
missing_primary = tmp_path / "nonexistent_primary" / ".credentials.json"
|
||||
present_localappdata = tmp_path / "localappdata" / ".credentials.json"
|
||||
missing_appdata = tmp_path / "nonexistent_appdata" / ".credentials.json"
|
||||
|
||||
present_localappdata.parent.mkdir(parents=True)
|
||||
present_localappdata.write_text(json.dumps({"accessToken": "sk-ant-test-LA"}))
|
||||
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_windows_credential_candidates",
|
||||
lambda: [missing_primary, present_localappdata, missing_appdata],
|
||||
)
|
||||
assert read_token() == "sk-ant-test-LA"
|
||||
|
||||
|
||||
def test_read_token_appdata_fallback(tmp_path, monkeypatch):
|
||||
"""read_token() falls back to %APPDATA%/Claude/.credentials.json when primary and LOCALAPPDATA are absent."""
|
||||
missing_primary = tmp_path / "nonexistent_primary" / ".credentials.json"
|
||||
missing_localappdata = tmp_path / "nonexistent_localappdata" / ".credentials.json"
|
||||
present_appdata = tmp_path / "appdata" / ".credentials.json"
|
||||
|
||||
present_appdata.parent.mkdir(parents=True)
|
||||
present_appdata.write_text(json.dumps({"accessToken": "sk-ant-test-APP"}))
|
||||
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_windows_credential_candidates",
|
||||
lambda: [missing_primary, missing_localappdata, present_appdata],
|
||||
)
|
||||
assert read_token() == "sk-ant-test-APP"
|
||||
|
||||
|
||||
def test_read_token_no_file(tmp_path, monkeypatch):
|
||||
"""read_token() returns None when no credential file can be found."""
|
||||
monkeypatch.setenv("CLAUDE_CREDENTIALS_PATH", str(tmp_path / "nonexistent.json"))
|
||||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||||
assert read_token() is None
|
||||
|
||||
|
||||
def test_read_token_config_dir_override(tmp_path, monkeypatch):
|
||||
"""read_token() honours the official CLAUDE_CONFIG_DIR env override."""
|
||||
creds = tmp_path / ".credentials.json"
|
||||
creds.write_text(json.dumps({"accessToken": "sk-ant-test-CFGDIR"}))
|
||||
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path))
|
||||
monkeypatch.delenv("CLAUDE_CREDENTIALS_PATH", raising=False)
|
||||
assert read_token() == "sk-ant-test-CFGDIR"
|
||||
|
||||
|
||||
def test_read_expiry_decodes_milliseconds(monkeypatch):
|
||||
"""_read_expiry() divides expiresAt by 1000 (ms -> s); fixture 9999999999000 -> year 2286."""
|
||||
monkeypatch.setenv("CLAUDE_CREDENTIALS_PATH", str(FIXTURES / "credentials_nested.json"))
|
||||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||||
result = _read_expiry()
|
||||
assert result.startswith("2286-"), f"Expected year 2286, got: {result}"
|
||||
|
||||
|
||||
# --- WR-03: regression guard for CR-01 (empty/blank token must not be accepted) ---
|
||||
|
||||
def test_extract_empty_token_is_none():
|
||||
"""_extract_access_token returns None for empty accessToken (CR-01 regression guard)."""
|
||||
assert _extract_access_token('{"accessToken": ""}') is None
|
||||
assert _extract_access_token('{}') is None
|
||||
|
||||
|
||||
|
||||
def test_read_token_empty_credential_file_returns_none(tmp_path, monkeypatch):
|
||||
"""read_token() returns None (not empty string) when credential file has empty accessToken."""
|
||||
creds = tmp_path / ".credentials.json"
|
||||
creds.write_text(json.dumps({"accessToken": ""}))
|
||||
monkeypatch.setenv("CLAUDE_CREDENTIALS_PATH", str(creds))
|
||||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||||
assert read_token() is None
|
||||
|
||||
|
||||
# --- WR-01: regression guard for _read_expiry with non-dict top-level JSON ---
|
||||
|
||||
def test_read_expiry_non_dict_json_returns_unknown(tmp_path, monkeypatch):
|
||||
"""_read_expiry() returns 'expiry unknown' (not crash) for non-dict top-level JSON (WR-01)."""
|
||||
creds = tmp_path / ".credentials.json"
|
||||
creds.write_text("[1, 2, 3]")
|
||||
monkeypatch.setenv("CLAUDE_CREDENTIALS_PATH", str(creds))
|
||||
monkeypatch.delenv("CLAUDE_CONFIG_DIR", raising=False)
|
||||
assert _read_expiry() == "expiry unknown"
|
||||
|
||||
|
||||
# --- WR-02: D-06 redaction requirement must be tested ---
|
||||
|
||||
def test_main_emits_linux_warning(monkeypatch):
|
||||
"""__main__ prints a non-fatal stderr warning on non-Windows platforms (new async runner).
|
||||
|
||||
Phase 2 replaced the Phase 1 token-printing __main__ with asyncio.run(main()). The
|
||||
new contract:
|
||||
- On non-Windows: emits "WinRT BLE will not be available" to stderr before the loop.
|
||||
- Enters the async scan/connect/poll loop (no longer prints token/expiry).
|
||||
This test interrupts the process after 3s to capture the warning without hanging.
|
||||
"""
|
||||
env = {**__import__("os").environ, "CLAUDE_CREDENTIALS_PATH": str(FIXTURES / "credentials_nested.json")}
|
||||
env.pop("CLAUDE_CONFIG_DIR", None)
|
||||
module = str(Path(__file__).parent.parent / "claude_usage_daemon_windows.py")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, module],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=3,
|
||||
)
|
||||
# If it exits cleanly, verify warning was emitted
|
||||
assert "WinRT BLE will not be available" in result.stderr
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# Process is hanging in the scan loop — expected behavior on Linux.
|
||||
# The warning should appear in the partial stderr captured so far.
|
||||
partial_stderr = (exc.stderr or b"").decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
||||
assert "WinRT BLE will not be available" in partial_stderr, (
|
||||
f"Expected Linux/WSL warning in stderr before scan loop, got: {partial_stderr!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_main_emits_linux_warning_before_loop(monkeypatch):
|
||||
"""__main__ stderr warning appears before the async scan loop starts on Linux/WSL."""
|
||||
import signal as _signal
|
||||
env = {**__import__("os").environ}
|
||||
env.pop("CLAUDE_CONFIG_DIR", None)
|
||||
env.pop("CLAUDE_CREDENTIALS_PATH", None)
|
||||
module = str(Path(__file__).parent.parent / "claude_usage_daemon_windows.py")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, module],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=3,
|
||||
)
|
||||
# If it exits cleanly (KeyboardInterrupt path), check warning
|
||||
assert "WinRT BLE will not be available" in result.stderr
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
# Process is hanging in the scan loop — expected behavior on Linux.
|
||||
# The warning should appear in the partial stderr captured so far.
|
||||
partial_stderr = (exc.stderr or b"").decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
||||
assert "WinRT BLE will not be available" in partial_stderr, (
|
||||
f"Expected Linux/WSL warning in stderr before scan loop, got: {partial_stderr!r}"
|
||||
)
|
||||
Reference in New Issue
Block a user