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,3 @@
|
||||
{
|
||||
"accessToken": "sk-ant-test-5678"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-test-1234",
|
||||
"refreshToken": "sk-ant-ort-test-5678",
|
||||
"expiresAt": 9999999999000,
|
||||
"scopes": ["user:inference", "user:profile"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for daemon/autostart_windows.py — APP-01.
|
||||
|
||||
Covers the winreg HKCU\\Run enable/disable/is_enabled login-autostart toggle.
|
||||
winreg is NOT importable off-Windows; these tests patch it via
|
||||
patch("daemon.autostart_windows.winreg", ...) so they run on any platform.
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_autostart.py -x -q
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers — build a fake winreg module with the attributes autostart_windows
|
||||
# references. Using a MagicMock as the module means all attribute accesses
|
||||
# on it (HKEY_CURRENT_USER, KEY_SET_VALUE, etc.) automatically produce child
|
||||
# MagicMocks, which is exactly what we want.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_winreg_mock(*, query_raises=False):
|
||||
"""Return a configured MagicMock that stands in for the winreg module."""
|
||||
winreg = MagicMock()
|
||||
|
||||
# Constants — assign simple sentinel values so equality checks work.
|
||||
winreg.HKEY_CURRENT_USER = "HKEY_CURRENT_USER"
|
||||
winreg.KEY_SET_VALUE = 0x0002
|
||||
winreg.KEY_QUERY_VALUE = 0x0001
|
||||
winreg.REG_SZ = 1
|
||||
|
||||
# OpenKey is used as a context manager; return a MagicMock key handle that
|
||||
# supports __enter__ / __exit__.
|
||||
key_handle = MagicMock()
|
||||
key_handle.__enter__ = MagicMock(return_value=key_handle)
|
||||
key_handle.__exit__ = MagicMock(return_value=False)
|
||||
winreg.OpenKey = MagicMock(return_value=key_handle)
|
||||
|
||||
# QueryValueEx behaviour is configured by the caller.
|
||||
if query_raises:
|
||||
winreg.QueryValueEx = MagicMock(side_effect=FileNotFoundError("not found"))
|
||||
else:
|
||||
winreg.QueryValueEx = MagicMock(return_value=("some_command", 1))
|
||||
|
||||
return winreg, key_handle
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_enable_writes_run_value
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_enable_writes_run_value():
|
||||
"""enable() opens HKCU Run key with KEY_SET_VALUE and calls SetValueEx with
|
||||
value name 'Clawdmeter' and type REG_SZ."""
|
||||
winreg, key_handle = _make_winreg_mock()
|
||||
|
||||
with patch("daemon.autostart_windows.winreg", winreg):
|
||||
import daemon.autostart_windows as mod
|
||||
mod.enable()
|
||||
|
||||
# OpenKey must have been called with HKCU and the Run key path
|
||||
winreg.OpenKey.assert_called_once_with(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Run",
|
||||
0,
|
||||
winreg.KEY_SET_VALUE,
|
||||
)
|
||||
|
||||
# SetValueEx must have been called with the correct value name and type
|
||||
winreg.SetValueEx.assert_called_once()
|
||||
args = winreg.SetValueEx.call_args[0]
|
||||
assert args[0] is key_handle, "SetValueEx first arg must be the opened key handle"
|
||||
assert args[1] == "Clawdmeter", "Value name must be 'Clawdmeter'"
|
||||
assert args[3] == winreg.REG_SZ, "Value type must be REG_SZ"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_command_uses_pythonw
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_command_uses_pythonw():
|
||||
"""The command string written by enable() contains 'pythonw.exe', does NOT
|
||||
contain a bare 'python.exe' token (D-08, no console), and is quoted (starts
|
||||
with a double-quote character)."""
|
||||
winreg, key_handle = _make_winreg_mock()
|
||||
|
||||
captured_commands = []
|
||||
|
||||
def capture_set_value_ex(key, name, reserved, reg_type, value):
|
||||
captured_commands.append(value)
|
||||
|
||||
winreg.SetValueEx = MagicMock(side_effect=capture_set_value_ex)
|
||||
|
||||
with patch("daemon.autostart_windows.winreg", winreg):
|
||||
import daemon.autostart_windows as mod
|
||||
mod.enable()
|
||||
|
||||
assert len(captured_commands) == 1, "SetValueEx must have been called exactly once"
|
||||
cmd = captured_commands[0]
|
||||
|
||||
# Must reference pythonw.exe (D-08)
|
||||
assert "pythonw.exe" in cmd, f"Command must contain 'pythonw.exe'; got: {cmd!r}"
|
||||
|
||||
# Must NOT contain a bare 'python.exe' (without the 'w') as a standalone token
|
||||
# A command like '"...pythonw.exe" ...' is fine; '"...python.exe" ...' is not.
|
||||
import re
|
||||
assert not re.search(r'(?<![a-z])python\.exe', cmd), (
|
||||
f"Command must not reference a bare 'python.exe'; got: {cmd!r}"
|
||||
)
|
||||
|
||||
# Must start with a double-quote (paths are quoted for space safety)
|
||||
assert cmd.startswith('"'), (
|
||||
f"Command must start with '\"' (quoted path); got: {cmd!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_disable_idempotent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_disable_idempotent():
|
||||
"""disable() calls DeleteValue; when DeleteValue raises FileNotFoundError,
|
||||
disable() swallows it and returns without raising (idempotent-on-missing)."""
|
||||
winreg, key_handle = _make_winreg_mock()
|
||||
winreg.DeleteValue = MagicMock(side_effect=FileNotFoundError("not found"))
|
||||
|
||||
with patch("daemon.autostart_windows.winreg", winreg):
|
||||
import daemon.autostart_windows as mod
|
||||
# Must not raise even though DeleteValue raises FileNotFoundError
|
||||
mod.disable() # no exception expected
|
||||
|
||||
winreg.DeleteValue.assert_called_once()
|
||||
|
||||
|
||||
def test_disable_calls_delete_value_with_correct_name():
|
||||
"""disable() calls DeleteValue with the value name 'Clawdmeter'."""
|
||||
winreg, key_handle = _make_winreg_mock()
|
||||
winreg.DeleteValue = MagicMock()
|
||||
|
||||
with patch("daemon.autostart_windows.winreg", winreg):
|
||||
import daemon.autostart_windows as mod
|
||||
mod.disable()
|
||||
|
||||
winreg.DeleteValue.assert_called_once()
|
||||
args = winreg.DeleteValue.call_args[0]
|
||||
assert args[1] == "Clawdmeter", f"DeleteValue must target 'Clawdmeter'; got {args[1]!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# test_is_enabled
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_enabled_true_when_value_present():
|
||||
"""is_enabled() returns True when QueryValueEx succeeds (value is present)."""
|
||||
winreg, key_handle = _make_winreg_mock(query_raises=False)
|
||||
|
||||
with patch("daemon.autostart_windows.winreg", winreg):
|
||||
import daemon.autostart_windows as mod
|
||||
result = mod.is_enabled()
|
||||
|
||||
assert result is True, "is_enabled() must return True when QueryValueEx succeeds"
|
||||
|
||||
|
||||
def test_is_enabled_false_when_value_absent():
|
||||
"""is_enabled() returns False when QueryValueEx raises FileNotFoundError."""
|
||||
winreg, key_handle = _make_winreg_mock(query_raises=True)
|
||||
|
||||
with patch("daemon.autostart_windows.winreg", winreg):
|
||||
import daemon.autostart_windows as mod
|
||||
result = mod.is_enabled()
|
||||
|
||||
assert result is False, "is_enabled() must return False when QueryValueEx raises FileNotFoundError"
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for daemon/icon_assets.py — APP-01 tray icon asset layer.
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_icon.py -x -q
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.icon_assets import _expand565, load_logo_rgba
|
||||
|
||||
# The "fixture" is the real in-repo firmware logo header — trusted asset.
|
||||
LOGO_H = Path(__file__).parent.parent.parent / "firmware" / "src" / "logo.h"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 1: logo parse + RGB565->RGB888 expand
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_logo_parse():
|
||||
"""load_logo_rgba returns an 80x80 RGBA image; dominant opaque color is #DE7552."""
|
||||
img = load_logo_rgba(str(LOGO_H))
|
||||
assert img.mode == "RGBA", f"Expected RGBA, got {img.mode}"
|
||||
assert img.size == (80, 80), f"Expected (80,80), got {img.size}"
|
||||
|
||||
# Collect all fully-opaque pixels and find the dominant RGB.
|
||||
from collections import Counter
|
||||
px = img.load()
|
||||
opaque = [
|
||||
(px[x, y][0], px[x, y][1], px[x, y][2])
|
||||
for y in range(80)
|
||||
for x in range(80)
|
||||
if px[x, y][3] == 255
|
||||
]
|
||||
assert opaque, "No fully-opaque pixels found in logo"
|
||||
dominant = Counter(opaque).most_common(1)[0][0]
|
||||
# Brand hex #DE7552 = (222, 117, 82)
|
||||
assert dominant == (222, 117, 82), (
|
||||
f"Expected dominant opaque color (222,117,82), got {dominant}"
|
||||
)
|
||||
|
||||
|
||||
def test_rgb565_expand():
|
||||
"""_expand565 uses proper rounding, not a *8 bit-shift."""
|
||||
assert _expand565(0xDBAA) == (222, 117, 82), (
|
||||
f"0xDBAA should be (222,117,82), got {_expand565(0xDBAA)}"
|
||||
)
|
||||
assert _expand565(0x0000) == (0, 0, 0), (
|
||||
f"0x0000 should be (0,0,0), got {_expand565(0x0000)}"
|
||||
)
|
||||
assert _expand565(0xFFFF) == (255, 255, 255), (
|
||||
f"0xFFFF should be (255,255,255), got {_expand565(0xFFFF)}"
|
||||
)
|
||||
|
||||
|
||||
def test_logo_parse_bounds_check():
|
||||
"""load_logo_rgba raises ValueError if the data array length != W*H*3."""
|
||||
import tempfile, os
|
||||
|
||||
# Write a malformed header with fewer bytes than expected
|
||||
malformed = (
|
||||
"#pragma once\n"
|
||||
"static const uint8_t logo_data[100] = {\n"
|
||||
" 0x00, 0x01, 0x02\n"
|
||||
"};\n"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".h", delete=False) as f:
|
||||
f.write(malformed)
|
||||
path = f.name
|
||||
try:
|
||||
with pytest.raises(ValueError):
|
||||
load_logo_rgba(path)
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 2: state->image corner-bubble compositor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_state_icon_bubble():
|
||||
"""state_icon returns distinct 32x32 RGBA images per state with correct bubble color."""
|
||||
from daemon.icon_assets import state_icon
|
||||
|
||||
base = load_logo_rgba(str(LOGO_H))
|
||||
connected = state_icon(base, "connected", 32)
|
||||
scanning = state_icon(base, "scanning", 32)
|
||||
error = state_icon(base, "error", 32)
|
||||
|
||||
# All are 32x32 RGBA
|
||||
for name, img in [("connected", connected), ("scanning", scanning), ("error", error)]:
|
||||
assert img.mode == "RGBA", f"{name}: expected RGBA, got {img.mode}"
|
||||
assert img.size == (32, 32), f"{name}: expected (32,32), got {img.size}"
|
||||
|
||||
# The three images must be pixel-distinct from each other
|
||||
def img_bytes(img):
|
||||
return img.tobytes()
|
||||
|
||||
assert img_bytes(connected) != img_bytes(scanning), "connected and scanning should differ"
|
||||
assert img_bytes(connected) != img_bytes(error), "connected and error should differ"
|
||||
assert img_bytes(scanning) != img_bytes(error), "scanning and error should differ"
|
||||
|
||||
# Sample the bottom-right corner region; the closest bubble color should match the state.
|
||||
# Bubble colors: connected (60,200,90), scanning (240,180,40), error (220,60,60)
|
||||
BUBBLE_COLORS = {
|
||||
"connected": (60, 200, 90),
|
||||
"scanning": (240, 180, 40),
|
||||
"error": (220, 60, 60),
|
||||
}
|
||||
|
||||
def color_distance(c1, c2):
|
||||
return sum((a - b) ** 2 for a, b in zip(c1, c2)) ** 0.5
|
||||
|
||||
def nearest_bubble(pixel_rgb):
|
||||
return min(BUBBLE_COLORS.items(), key=lambda kv: color_distance(pixel_rgb, kv[1]))[0]
|
||||
|
||||
size = 32
|
||||
r = size // 3
|
||||
# Sample the center of the expected bubble region (bottom-right corner)
|
||||
bx = size - r // 2 - 2
|
||||
by = size - r // 2 - 2
|
||||
bx = max(0, min(bx, size - 1))
|
||||
by = max(0, min(by, size - 1))
|
||||
|
||||
for state_name, img in [("connected", connected), ("scanning", scanning), ("error", error)]:
|
||||
px = img.load()
|
||||
pixel = px[bx, by][:3] # RGB only
|
||||
nearest = nearest_bubble(pixel)
|
||||
assert nearest == state_name, (
|
||||
f"State '{state_name}': bottom-right corner pixel {pixel} is nearest to "
|
||||
f"'{nearest}' bubble, expected '{state_name}'"
|
||||
)
|
||||
|
||||
|
||||
def test_build_icons_once():
|
||||
"""build_state_icons returns a dict with connected/scanning/error as distinct Images."""
|
||||
from daemon.icon_assets import build_state_icons
|
||||
|
||||
base = load_logo_rgba(str(LOGO_H))
|
||||
icons = build_state_icons(base)
|
||||
|
||||
assert set(icons.keys()) == {"connected", "scanning", "error"}, (
|
||||
f"Expected keys connected/scanning/error, got {set(icons.keys())}"
|
||||
)
|
||||
|
||||
# All distinct
|
||||
def img_bytes(img):
|
||||
return img.tobytes()
|
||||
|
||||
imgs = list(icons.values())
|
||||
assert img_bytes(imgs[0]) != img_bytes(imgs[1])
|
||||
assert img_bytes(imgs[0]) != img_bytes(imgs[2])
|
||||
assert img_bytes(imgs[1]) != img_bytes(imgs[2])
|
||||
|
||||
|
||||
def test_state_icon_unknown_state():
|
||||
"""state_icon raises KeyError/ValueError on an unknown state string."""
|
||||
from daemon.icon_assets import state_icon
|
||||
|
||||
base = load_logo_rgba(str(LOGO_H))
|
||||
with pytest.raises((KeyError, ValueError)):
|
||||
state_icon(base, "unknown_state", 32)
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static no-WSL-paths regression guard — APP-02 / D-10.
|
||||
|
||||
Asserts that the daemon, tray, and autostart sources reference no WSL-specific
|
||||
paths. This is a CI-surviving regression lock that needs no hardware: if a
|
||||
future edit accidentally introduces a ``\\wsl$``, ``wsl.exe``, ``/home/``, or
|
||||
``/mnt/`` reference into any of the three core Windows daemon source files, this
|
||||
test will fail with a message that names the offending pattern and file.
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_no_wsl.py -x -q
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# The four WSL-path patterns that must never appear in the daemon sources.
|
||||
FORBIDDEN = [r"\\wsl\$", r"wsl\.exe", r"/home/", r"/mnt/"]
|
||||
|
||||
# The three Windows-daemon source files covered by the guard.
|
||||
SOURCES = [
|
||||
Path("daemon/claude_usage_daemon_windows.py"),
|
||||
Path("daemon/tray_windows.py"),
|
||||
Path("daemon/autostart_windows.py"),
|
||||
]
|
||||
|
||||
|
||||
def test_no_wsl_paths_in_daemon():
|
||||
"""daemon/claude_usage_daemon_windows.py references no WSL paths."""
|
||||
_assert_clean(Path("daemon/claude_usage_daemon_windows.py"))
|
||||
|
||||
|
||||
def test_no_wsl_paths_in_tray():
|
||||
"""daemon/tray_windows.py references no WSL paths."""
|
||||
_assert_clean(Path("daemon/tray_windows.py"))
|
||||
|
||||
|
||||
def test_no_wsl_paths_in_autostart():
|
||||
"""daemon/autostart_windows.py references no WSL paths."""
|
||||
_assert_clean(Path("daemon/autostart_windows.py"))
|
||||
|
||||
|
||||
def _assert_clean(source: Path) -> None:
|
||||
"""Assert that none of the FORBIDDEN patterns appear in the given source file.
|
||||
|
||||
Reads the file relative to the repository root (the cwd pytest is invoked
|
||||
from). Fails with a descriptive message naming the leaked pattern and file
|
||||
so the regression is immediately actionable.
|
||||
"""
|
||||
text = source.read_text(encoding="utf-8")
|
||||
for pat in FORBIDDEN:
|
||||
match = re.search(pat, text)
|
||||
assert match is None, (
|
||||
f"WSL path leaked into {source}: pattern {pat!r} found at "
|
||||
f"position {match.start()} — "
|
||||
f"context: {text[max(0, match.start()-20):match.end()+20]!r}"
|
||||
)
|
||||
@@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for poll_api / pct / reset_minutes / JSON-shape — POLL-01.
|
||||
|
||||
These tests cover the Anthropic API polling logic ported from the macOS daemon.
|
||||
All tests mock httpx so no real network calls are made.
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_poll.py -x -q
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.claude_usage_daemon_windows import AuthError, poll_api
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_response(status_code=200, headers=None):
|
||||
"""Build a mock httpx.Response-like object with controllable headers."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.text = "mocked"
|
||||
# httpx headers are case-insensitive; MagicMock .get() must behave the same
|
||||
header_data = headers or {}
|
||||
resp.headers = MagicMock()
|
||||
resp.headers.get = lambda name, default=None: header_data.get(name.lower(), default)
|
||||
return resp
|
||||
|
||||
|
||||
def _run(coro):
|
||||
"""Run a coroutine synchronously for synchronous test functions."""
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: full poll_api with realistic ratelimit headers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_poll_api_nominal(monkeypatch):
|
||||
"""poll_api with a 200 response + ratelimit headers produces the correct payload."""
|
||||
now = time.time()
|
||||
reset_5h = str(now + 3600) # 60 minutes from now
|
||||
reset_7d = str(now + 86400) # 1440 minutes from now
|
||||
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.42",
|
||||
"anthropic-ratelimit-unified-5h-reset": reset_5h,
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.10",
|
||||
"anthropic-ratelimit-unified-7d-reset": reset_7d,
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
async def fake_post(*args, **kwargs):
|
||||
return mock_resp
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = fake_post
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload is not None
|
||||
assert payload["s"] == 42
|
||||
assert payload["w"] == 10
|
||||
assert payload["st"] == "allowed"
|
||||
assert payload["ok"] is True
|
||||
# reset_minutes allows ±1 minute tolerance
|
||||
assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}"
|
||||
assert abs(payload["wr"] - 1440) <= 1, f"Expected ~1440, got {payload['wr']}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: pct() correctness — exercised through poll_api output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_pct_42_percent(monkeypatch):
|
||||
"""pct('0.42') -> 42."""
|
||||
now = time.time()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.42",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.10",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload["s"] == 42
|
||||
|
||||
|
||||
def test_pct_100_percent(monkeypatch):
|
||||
"""pct('1.0') -> 100."""
|
||||
now = time.time()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "1.0",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-utilization": "1.0",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload["s"] == 100
|
||||
assert payload["w"] == 100
|
||||
|
||||
|
||||
def test_pct_empty_string_defaults_to_zero(monkeypatch):
|
||||
"""pct('') -> 0 (missing header defaults to '0', but empty string -> 0)."""
|
||||
now = time.time()
|
||||
# Override default so utilization header returns "" explicitly
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload["s"] == 0
|
||||
assert payload["w"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: reset_minutes() — exercised through poll_api output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_reset_minutes_60_minutes(monkeypatch):
|
||||
"""reset_minutes(now+3600) -> ~60."""
|
||||
now = time.time()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert abs(payload["sr"] - 60) <= 1, f"Expected ~60, got {payload['sr']}"
|
||||
|
||||
|
||||
def test_reset_minutes_negative_clamps_to_zero(monkeypatch):
|
||||
"""reset_minutes for a past timestamp clamps to 0."""
|
||||
now = time.time()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now - 100), # 100s in the past
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now - 100), # also in the past
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload["sr"] == 0
|
||||
assert payload["wr"] == 0
|
||||
|
||||
|
||||
def test_reset_minutes_invalid_string_returns_zero(monkeypatch):
|
||||
"""reset_minutes('notanumber') -> 0 (ValueError-safe)."""
|
||||
now = time.time()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-5h-reset": "notanumber",
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-7d-reset": "notanumber",
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload["sr"] == 0
|
||||
assert payload["wr"] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: missing headers default gracefully
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_missing_utilization_headers_default_to_zero(monkeypatch):
|
||||
"""Missing utilization headers produce 0 (hdr default '0' -> pct('0') = 0)."""
|
||||
now = time.time()
|
||||
# No utilization or status headers — only reset headers present
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload["s"] == 0
|
||||
assert payload["w"] == 0
|
||||
|
||||
|
||||
def test_missing_status_header_defaults_to_unknown(monkeypatch):
|
||||
"""Missing 5h-status header defaults to 'unknown'."""
|
||||
now = time.time()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
# NOTE: no 5h-status header
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload["st"] == "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: poll_api returns None on HTTP >= 400
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_poll_api_returns_none_on_4xx(monkeypatch):
|
||||
"""poll_api returns None when response status code is >= 400."""
|
||||
mock_resp = _make_mock_response(status_code=429, headers={})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = _run(poll_api("fake-token"))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_poll_api_returns_none_on_5xx(monkeypatch):
|
||||
"""poll_api returns None when response status code is >= 500."""
|
||||
mock_resp = _make_mock_response(status_code=500, headers={})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = _run(poll_api("fake-token"))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: poll_api raises AuthError ONLY on a genuine 401/403 (SC#5 fix)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
def test_poll_api_raises_autherror_on_401_403(status):
|
||||
"""A real auth rejection must raise AuthError — the only signal that warrants
|
||||
the actionable 'token expired — run claude login' toast. Transient failures
|
||||
(5xx, 429, network) return None instead and must NOT trigger that toast."""
|
||||
mock_resp = _make_mock_response(status_code=status, headers={})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(AuthError):
|
||||
_run(poll_api("fake-token"))
|
||||
|
||||
|
||||
def test_poll_api_returns_none_not_autherror_on_429(monkeypatch):
|
||||
"""Rate-limit (429) is transient — None, NOT AuthError (regression guard for
|
||||
the 401/403-vs-other-4xx split)."""
|
||||
mock_resp = _make_mock_response(status_code=429, headers={})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = _run(poll_api("fake-token"))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: poll_api returns None on httpx.HTTPError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_poll_api_returns_none_on_http_error(monkeypatch):
|
||||
"""poll_api returns None when httpx.HTTPError is raised (network failure)."""
|
||||
import httpx
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = _run(poll_api("fake-token"))
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: compact JSON wire shape (no spaces after ':' or ',')
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_wire_bytes_compact_json_shape(monkeypatch):
|
||||
"""The JSON-encoded payload uses compact separators (',':') — no spaces."""
|
||||
now = time.time()
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.42",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.10",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
payload = _run(poll_api("fake-token"))
|
||||
|
||||
assert payload is not None
|
||||
# Encode exactly as the wire layer will (Session.write_payload uses this form)
|
||||
wire_bytes = json.dumps(payload, separators=(",", ":")).encode()
|
||||
wire_str = wire_bytes.decode()
|
||||
|
||||
# Compact form: no space after ':' or ','
|
||||
assert ": " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
|
||||
assert ", " not in wire_str, f"Non-compact JSON detected: {wire_str!r}"
|
||||
|
||||
# Must start with '{' and contain all required keys
|
||||
assert wire_str.startswith("{")
|
||||
for key in ("s", "sr", "w", "wr", "st", "ok"):
|
||||
assert f'"{key}"' in wire_str, f"Missing key {key!r} in wire bytes: {wire_str!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test: token is NOT logged (T-02-01 threat mitigation)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_poll_api_does_not_log_token(monkeypatch, capsys):
|
||||
"""poll_api must not print the bearer token (T-02-01: token never logged)."""
|
||||
now = time.time()
|
||||
secret_token = "sk-ant-secret-token-12345"
|
||||
|
||||
mock_resp = _make_mock_response(
|
||||
status_code=200,
|
||||
headers={
|
||||
"anthropic-ratelimit-unified-5h-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-5h-reset": str(now + 3600),
|
||||
"anthropic-ratelimit-unified-7d-utilization": "0.5",
|
||||
"anthropic-ratelimit-unified-7d-reset": str(now + 86400),
|
||||
"anthropic-ratelimit-unified-5h-status": "allowed",
|
||||
},
|
||||
)
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
_run(poll_api(secret_token))
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert secret_token not in captured.out, "Token leaked to stdout (T-02-01 violation)"
|
||||
assert secret_token not in captured.err, "Token leaked to stderr (T-02-01 violation)"
|
||||
@@ -0,0 +1,723 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for connect_and_run reconnect hardening — BLE-03.
|
||||
|
||||
Covers:
|
||||
D-01: connect-retry wrapper (post-wake WinRT failure modes)
|
||||
D-03: zombie-link consecutive-failure break (stale is_connected)
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_reconnect.py -x -q
|
||||
"""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from bleak.exc import BleakError
|
||||
|
||||
from daemon.claude_usage_daemon_windows import (
|
||||
AuthError,
|
||||
Session,
|
||||
_wait_first,
|
||||
connect_and_run,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _run(coro):
|
||||
"""Run a coroutine synchronously for synchronous test functions."""
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
def _make_device(address="AA:BB:CC:DD:EE:FF"):
|
||||
"""Build a minimal fake BLEDevice."""
|
||||
device = MagicMock()
|
||||
device.address = address
|
||||
return device
|
||||
|
||||
|
||||
async def _make_event(set_):
|
||||
ev = asyncio.Event()
|
||||
if set_:
|
||||
ev.set()
|
||||
return ev
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-01: connect-retry wrapper tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_connect_retry_exhaustion_on_bleak_error(monkeypatch, capsys):
|
||||
"""BleakError on every connect attempt exhausts CONNECT_RETRIES then returns False."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
|
||||
mock_client.is_connected = False
|
||||
mock_client.disconnect = AsyncMock()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
||||
result = _run(connect_and_run(device, stop_event))
|
||||
|
||||
assert result is False
|
||||
assert mock_client.connect.call_count == mod.CONNECT_RETRIES
|
||||
|
||||
|
||||
def test_connect_retry_exhaustion_on_timeout_error(monkeypatch, capsys):
|
||||
"""asyncio.TimeoutError on every connect attempt is treated same as BleakError."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.connect = AsyncMock(side_effect=asyncio.TimeoutError())
|
||||
mock_client.is_connected = False
|
||||
mock_client.disconnect = AsyncMock()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
||||
result = _run(connect_and_run(device, stop_event))
|
||||
|
||||
assert result is False
|
||||
assert mock_client.connect.call_count == mod.CONNECT_RETRIES
|
||||
|
||||
|
||||
def test_connect_retry_calls_disconnect_between_attempts(monkeypatch):
|
||||
"""Guarded disconnect() is called between failed connect attempts."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
|
||||
mock_client.is_connected = False
|
||||
mock_client.disconnect = AsyncMock()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
||||
_run(connect_and_run(device, stop_event))
|
||||
|
||||
# disconnect is called between attempts (at least CONNECT_RETRIES - 1 times)
|
||||
assert mock_client.disconnect.call_count >= mod.CONNECT_RETRIES - 1
|
||||
|
||||
|
||||
def test_connect_success_on_first_attempt_no_extra_retries(monkeypatch):
|
||||
"""First-attempt success consumes exactly 1 connect call and proceeds past connect block."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
# stop_event is set so the loop exits immediately after connecting
|
||||
stop_event = asyncio.run(_make_event(True))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.connect = AsyncMock(return_value=None) # success
|
||||
mock_client.is_connected = True
|
||||
mock_client.disconnect = AsyncMock()
|
||||
mock_client.start_notify = AsyncMock()
|
||||
mock_client.write_gatt_char = AsyncMock(return_value=None)
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api", new=AsyncMock(return_value={"ok": True})):
|
||||
_run(connect_and_run(device, stop_event))
|
||||
|
||||
assert mock_client.connect.call_count == 1
|
||||
|
||||
|
||||
def test_connect_retry_exhaustion_does_not_log_token(monkeypatch, capsys):
|
||||
"""On exhaustion, no log line contains the patched token sentinel (T-03-01)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
TOKEN_SENTINEL = "sk-ant-SUPERSECRET-DO-NOT-LOG-12345"
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.connect = AsyncMock(side_effect=BleakError("Unreachable"))
|
||||
mock_client.is_connected = False
|
||||
mock_client.disconnect = AsyncMock()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value=TOKEN_SENTINEL), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.sleep", new=AsyncMock()):
|
||||
_run(connect_and_run(device, stop_event))
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert TOKEN_SENTINEL not in captured.out, "Token sentinel leaked to stdout (T-03-01)"
|
||||
assert TOKEN_SENTINEL not in captured.err, "Token sentinel leaked to stderr (T-03-01)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-03: zombie-link consecutive-failure break tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_zombie_client():
|
||||
"""Build a mock BleakClient that connects successfully but has is_connected stuck True."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.connect = AsyncMock(return_value=None)
|
||||
mock_client.is_connected = True # stale flag — never goes False
|
||||
mock_client.disconnect = AsyncMock()
|
||||
mock_client.start_notify = AsyncMock()
|
||||
return mock_client
|
||||
|
||||
|
||||
def test_zombie_link_break_after_limit_consecutive_failures(monkeypatch):
|
||||
"""Loop breaks after exactly ZOMBIE_BREAK_LIMIT consecutive False writes (default 1)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
mock_client = _make_zombie_client()
|
||||
|
||||
write_call_count = [0]
|
||||
|
||||
async def fake_write_payload(payload):
|
||||
write_call_count[0] += 1
|
||||
return False # always fail — zombie link
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.write_payload = fake_write_payload
|
||||
fake_session.refresh_requested = MagicMock()
|
||||
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
||||
fake_session.refresh_requested.clear = MagicMock()
|
||||
fake_session.refresh_requested.wait = AsyncMock()
|
||||
|
||||
# Force elapsed >= POLL_INTERVAL immediately
|
||||
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
||||
|
||||
async def fast_wait_for(coro, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api",
|
||||
new=AsyncMock(return_value={"ok": True})), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
||||
side_effect=fast_wait_for):
|
||||
result = _run(connect_and_run(device, stop_event))
|
||||
|
||||
# With ZOMBIE_BREAK_LIMIT=1, one False write should break the loop
|
||||
assert write_call_count[0] == mod.ZOMBIE_BREAK_LIMIT
|
||||
# Should return used_successfully=False (no successful write)
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_zombie_counter_resets_on_success_with_raised_limit(monkeypatch):
|
||||
"""A failed write followed by success resets counter (limit raised to 2 to exercise reset)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
mock_client = _make_zombie_client()
|
||||
|
||||
# Sequence: False (counter=1), True (counter reset to 0), False (counter=1 again), break
|
||||
write_results = iter([False, True, False])
|
||||
write_call_count = [0]
|
||||
|
||||
async def fake_write_payload(payload):
|
||||
write_call_count[0] += 1
|
||||
try:
|
||||
return next(write_results)
|
||||
except StopIteration:
|
||||
return False
|
||||
|
||||
# After success, subsequent False write breaks at limit=2 (requires 2 consecutive)
|
||||
# With limit=2: False (1), True (reset to 0), False (1), False (2 -> break)
|
||||
# But we only have 3 items in write_results; after StopIteration returns False.
|
||||
# Let's use a longer sequence to ensure reset-then-2-failures trip the break.
|
||||
write_results2 = [False, True, False, False]
|
||||
write_call_count2 = [0]
|
||||
|
||||
async def fake_write_payload2(payload):
|
||||
write_call_count2[0] += 1
|
||||
if write_call_count2[0] - 1 < len(write_results2):
|
||||
return write_results2[write_call_count2[0] - 1]
|
||||
return False
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.write_payload = fake_write_payload2
|
||||
fake_session.refresh_requested = MagicMock()
|
||||
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
||||
fake_session.refresh_requested.clear = MagicMock()
|
||||
fake_session.refresh_requested.wait = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
||||
monkeypatch.setattr(mod, "ZOMBIE_BREAK_LIMIT", 2) # raise limit to test reset logic
|
||||
|
||||
async def fast_wait_for(coro, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api",
|
||||
new=AsyncMock(return_value={"ok": True})), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
||||
side_effect=fast_wait_for):
|
||||
result = _run(connect_and_run(device, stop_event))
|
||||
|
||||
# With limit=2 and sequence [False, True, False, False]:
|
||||
# cycle 1: False -> consecutive_failures=1 (no break, limit=2)
|
||||
# cycle 2: True -> consecutive_failures=0 (reset)
|
||||
# cycle 3: False -> consecutive_failures=1 (no break)
|
||||
# cycle 4: False -> consecutive_failures=2 -> break
|
||||
assert write_call_count2[0] == 4, (
|
||||
f"Expected 4 write calls (reset-on-success logic), got {write_call_count2[0]}"
|
||||
)
|
||||
# used_successfully=True because cycle 2 succeeded
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_zombie_break_disconnect_called_in_finally(monkeypatch):
|
||||
"""The finally block calls client.disconnect() exactly once on the zombie-break path."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
mock_client = _make_zombie_client()
|
||||
|
||||
async def fake_write_payload(payload):
|
||||
return False # always fail
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.write_payload = fake_write_payload
|
||||
fake_session.refresh_requested = MagicMock()
|
||||
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
||||
fake_session.refresh_requested.clear = MagicMock()
|
||||
fake_session.refresh_requested.wait = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
||||
|
||||
async def fast_wait_for(coro, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api",
|
||||
new=AsyncMock(return_value={"ok": True})), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
||||
side_effect=fast_wait_for):
|
||||
_run(connect_and_run(device, stop_event))
|
||||
|
||||
# The finally block calls disconnect() exactly once
|
||||
assert mock_client.disconnect.call_count == 1
|
||||
|
||||
|
||||
def test_zombie_break_returns_used_successfully_false(monkeypatch):
|
||||
"""connect_and_run returns used_successfully=False after zombie break with no writes."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
mock_client = _make_zombie_client()
|
||||
|
||||
async def fake_write_payload(payload):
|
||||
return False
|
||||
|
||||
fake_session = AsyncMock()
|
||||
fake_session.write_payload = fake_write_payload
|
||||
fake_session.refresh_requested = MagicMock()
|
||||
fake_session.refresh_requested.is_set = MagicMock(return_value=False)
|
||||
fake_session.refresh_requested.clear = MagicMock()
|
||||
fake_session.refresh_requested.wait = AsyncMock()
|
||||
|
||||
monkeypatch.setattr(mod, "POLL_INTERVAL", 0)
|
||||
|
||||
async def fast_wait_for(coro, timeout):
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.Session", return_value=fake_session), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api",
|
||||
new=AsyncMock(return_value={"ok": True})), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for",
|
||||
side_effect=fast_wait_for):
|
||||
result = _run(connect_and_run(device, stop_event))
|
||||
|
||||
# main() uses this return value to route into reconnect branch
|
||||
assert result is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# D-05: split fast-reconnect vs slow-search backoff in main()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_next_backoff_slow_search_doubles_to_60():
|
||||
"""_next_backoff doubles correctly and never exceeds 60 (slow-search cap)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
values = []
|
||||
b = 1
|
||||
for _ in range(10):
|
||||
b = mod._next_backoff(b, 60)
|
||||
values.append(b)
|
||||
|
||||
assert values == [2, 4, 8, 16, 32, 60, 60, 60, 60, 60]
|
||||
assert max(values) <= 60
|
||||
|
||||
|
||||
def test_next_backoff_fast_reconnect_doubles_to_cap():
|
||||
"""_next_backoff doubles correctly and never exceeds RECONNECT_BACKOFF_CAP (default 8)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
cap = mod.RECONNECT_BACKOFF_CAP
|
||||
assert cap < 60, "Fast cap must be strictly lower than search cap"
|
||||
|
||||
values = []
|
||||
b = 1
|
||||
for _ in range(8):
|
||||
b = mod._next_backoff(b, cap)
|
||||
values.append(b)
|
||||
|
||||
# Should double until hitting the cap, then stay there
|
||||
assert max(values) <= cap
|
||||
# Should reach the cap (not just stay at 1)
|
||||
assert values[-1] == cap
|
||||
|
||||
|
||||
def test_next_backoff_one_to_two():
|
||||
"""_next_backoff(1, 60) == 2 (basic sanity)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
assert mod._next_backoff(1, 60) == 2
|
||||
|
||||
|
||||
def test_next_backoff_at_cap_stays():
|
||||
"""_next_backoff(cap, cap) == cap (does not overflow)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
assert mod._next_backoff(mod.RECONNECT_BACKOFF_CAP, mod.RECONNECT_BACKOFF_CAP) == mod.RECONNECT_BACKOFF_CAP
|
||||
|
||||
|
||||
def test_main_scan_miss_uses_search_backoff():
|
||||
"""When scan_for_device returns None, asyncio.wait_for receives search_backoff timeout values."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
# Capture main()'s internal stop_event by intercepting asyncio.Event()
|
||||
internal_stop_event = [None]
|
||||
real_Event = asyncio.Event
|
||||
|
||||
def capturing_Event():
|
||||
ev = real_Event()
|
||||
internal_stop_event[0] = ev
|
||||
return ev
|
||||
|
||||
recorded_timeouts = []
|
||||
call_count = [0]
|
||||
MAX_CALLS = 3
|
||||
|
||||
async def fake_scan():
|
||||
return None # always miss -> slow-search regime
|
||||
|
||||
async def fake_wait_for(coro, timeout):
|
||||
recorded_timeouts.append(timeout)
|
||||
call_count[0] += 1
|
||||
if call_count[0] >= MAX_CALLS and internal_stop_event[0] is not None:
|
||||
internal_stop_event[0].set() # terminate main()'s outer while loop
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
|
||||
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
|
||||
_run(mod.main())
|
||||
|
||||
# Should have recorded timeouts from search_backoff sequence: 1, 2, 4 (then stop)
|
||||
assert len(recorded_timeouts) >= 2
|
||||
# Timeouts should be doubling (search_backoff sequence)
|
||||
assert recorded_timeouts[0] == 1
|
||||
assert recorded_timeouts[1] == 2
|
||||
# None should exceed the search cap (60)
|
||||
assert all(t <= 60 for t in recorded_timeouts)
|
||||
|
||||
|
||||
def test_main_connect_fail_uses_reconnect_backoff():
|
||||
"""When connect_and_run returns False, asyncio.wait_for receives reconnect_backoff timeouts (fast cap)."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
# Capture main()'s internal stop_event
|
||||
internal_stop_event = [None]
|
||||
real_Event = asyncio.Event
|
||||
|
||||
def capturing_Event():
|
||||
ev = real_Event()
|
||||
internal_stop_event[0] = ev
|
||||
return ev
|
||||
|
||||
fake_device = _make_device()
|
||||
recorded_timeouts = []
|
||||
call_count = [0]
|
||||
MAX_CALLS = 3
|
||||
|
||||
async def fake_scan():
|
||||
return fake_device # always finds device
|
||||
|
||||
async def fake_connect_and_run(device, event, tray_state=None):
|
||||
return False # always fails -> fast-reconnect regime
|
||||
|
||||
async def fake_wait_for(coro, timeout):
|
||||
recorded_timeouts.append(timeout)
|
||||
call_count[0] += 1
|
||||
if call_count[0] >= MAX_CALLS and internal_stop_event[0] is not None:
|
||||
internal_stop_event[0].set()
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
|
||||
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
|
||||
patch("daemon.claude_usage_daemon_windows.connect_and_run", side_effect=fake_connect_and_run), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
|
||||
_run(mod.main())
|
||||
|
||||
# Should have recorded timeouts from reconnect_backoff sequence: 1, 2, 4 (then stop)
|
||||
assert len(recorded_timeouts) >= 2
|
||||
assert recorded_timeouts[0] == 1
|
||||
assert recorded_timeouts[1] == 2
|
||||
# All timeouts must be at or below RECONNECT_BACKOFF_CAP (fast cap, < 60)
|
||||
assert all(t <= mod.RECONNECT_BACKOFF_CAP for t in recorded_timeouts)
|
||||
|
||||
|
||||
def test_main_reconnect_backoff_reset_on_success():
|
||||
"""A successful connect_and_run (returns True) resets reconnect_backoff to 1."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
# Capture main()'s internal stop_event
|
||||
internal_stop_event = [None]
|
||||
real_Event = asyncio.Event
|
||||
|
||||
def capturing_Event():
|
||||
ev = real_Event()
|
||||
internal_stop_event[0] = ev
|
||||
return ev
|
||||
|
||||
fake_device = _make_device()
|
||||
recorded_timeouts = []
|
||||
call_count = [0]
|
||||
|
||||
# Sequence: fail (reconnect_backoff=1), succeed (reset), fail (reconnect_backoff=1 again)
|
||||
connect_results = [False, True, False]
|
||||
connect_idx = [0]
|
||||
|
||||
async def fake_scan():
|
||||
return fake_device
|
||||
|
||||
async def fake_connect_and_run(device, event, tray_state=None):
|
||||
idx = connect_idx[0]
|
||||
connect_idx[0] += 1
|
||||
if idx < len(connect_results):
|
||||
return connect_results[idx]
|
||||
return False
|
||||
|
||||
async def fake_wait_for(coro, timeout):
|
||||
recorded_timeouts.append(timeout)
|
||||
call_count[0] += 1
|
||||
if call_count[0] >= 2 and internal_stop_event[0] is not None:
|
||||
internal_stop_event[0].set() # stop after 2 waits (first fail + post-success fail)
|
||||
raise asyncio.TimeoutError()
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.asyncio.Event", side_effect=capturing_Event), \
|
||||
patch("daemon.claude_usage_daemon_windows.scan_for_device", side_effect=fake_scan), \
|
||||
patch("daemon.claude_usage_daemon_windows.connect_and_run", side_effect=fake_connect_and_run), \
|
||||
patch("daemon.claude_usage_daemon_windows.asyncio.wait_for", side_effect=fake_wait_for):
|
||||
_run(mod.main())
|
||||
|
||||
# First wait: reconnect_backoff=1 (initial failure)
|
||||
# Second wait: reconnect_backoff=1 again (reset by success, then another failure)
|
||||
assert len(recorded_timeouts) >= 2
|
||||
assert recorded_timeouts[0] == 1, f"Expected 1 on first fail, got {recorded_timeouts[0]}"
|
||||
assert recorded_timeouts[1] == 1, f"Expected 1 after success reset, got {recorded_timeouts[1]}"
|
||||
|
||||
|
||||
def test_main_no_saved_addr_file_or_skip_addr():
|
||||
"""main() does not reference SAVED_ADDR_FILE or skip_addr (Windows is stateless - D-04)."""
|
||||
import inspect
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
source = inspect.getsource(mod.main)
|
||||
assert "SAVED_ADDR_FILE" not in source, "main() must not reference SAVED_ADDR_FILE (D-04)"
|
||||
assert "skip_addr" not in source, "main() must not reference skip_addr (macOS-only)"
|
||||
assert "retrieve_connected" not in source.lower(), \
|
||||
"main() must not reference retrieve_connected (macOS HID path)"
|
||||
|
||||
|
||||
def test_requirements_windows_contains_required_deps():
|
||||
"""requirements-windows.txt must contain the expected deps.
|
||||
|
||||
Phase 3 (reconnect) added no new deps; Phase 4 (tray) adds pystray + Pillow.
|
||||
This test asserts the final expected state: bleak, httpx, pystray, Pillow
|
||||
must be present; winreg must NOT be listed (it is stdlib — no install needed).
|
||||
"""
|
||||
req_path = Path(__file__).parent.parent / "requirements-windows.txt"
|
||||
content = req_path.read_text()
|
||||
lines = {line.strip().lower() for line in content.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")}
|
||||
|
||||
assert "bleak" in lines, "bleak must be in requirements-windows.txt"
|
||||
assert "httpx" in lines, "httpx must be in requirements-windows.txt"
|
||||
assert "pystray" in lines, "pystray must be in requirements-windows.txt (Phase 4)"
|
||||
assert "pillow" in lines, "Pillow must be in requirements-windows.txt (Phase 4)"
|
||||
assert "winreg" not in lines, "winreg is stdlib — must NOT be in requirements-windows.txt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# G-03-01: start_notify() OSError must not crash the daemon (SC#3 power-cycle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_start_notify_oserror_does_not_crash_connect_and_run():
|
||||
"""G-03-01 regression: on post-power-cycle reconnect, WinRT's start_notify()
|
||||
CCCD write can raise a raw OSError/WinError when the just-rebooted peer GATT
|
||||
server is not yet ready. The optional refresh subscription must degrade
|
||||
gracefully — connect_and_run must NOT propagate the OSError and must proceed
|
||||
into the poll loop (returning normally), so the daemon never restarts (SC#3/SC#4).
|
||||
"""
|
||||
device = _make_device()
|
||||
# stop_event set so the poll loop exits immediately after subscription setup
|
||||
stop_event = asyncio.run(_make_event(True))
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.connect = AsyncMock(return_value=None) # connect succeeds
|
||||
mock_client.is_connected = True
|
||||
mock_client.disconnect = AsyncMock()
|
||||
# The exact failure observed on hardware (SC#3, 2026-06-02):
|
||||
# OSError: [WinError -2147023673] The operation was canceled by the user.
|
||||
mock_client.start_notify = AsyncMock(
|
||||
side_effect=OSError(-2147023673, "The operation was canceled by the user.")
|
||||
)
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=mock_client), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="fake-token"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api", new=AsyncMock(return_value={"ok": True})):
|
||||
# Must NOT raise OSError — graceful degradation into the poll loop.
|
||||
result = _run(connect_and_run(device, stop_event))
|
||||
|
||||
# start_notify was actually attempted (and raised), but was swallowed.
|
||||
assert mock_client.start_notify.call_count == 1
|
||||
# Function returned normally instead of propagating the OSError.
|
||||
assert result is False
|
||||
# The link was cleaned up via the finally block.
|
||||
assert mock_client.disconnect.call_count >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC#2 field report: write_payload() OSError must not crash the daemon thread
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_write_payload_oserror_returns_false_not_raises():
|
||||
"""SC#2 regression: write_gatt_char can raise a raw OSError/WinError (NOT a
|
||||
BleakError) when the peer GATT server goes transiently unavailable mid-write.
|
||||
write_payload must catch it and return False — tripping the zombie-link break
|
||||
for a clean reconnect — instead of propagating an uncaught exception that
|
||||
silently kills the daemon=True background thread and freezes the tray.
|
||||
"""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.write_gatt_char = AsyncMock(
|
||||
side_effect=OSError(-2147023673, "The operation was canceled by the user.")
|
||||
)
|
||||
session = Session(mock_client)
|
||||
|
||||
result = _run(session.write_payload({"ok": True}))
|
||||
|
||||
assert result is False # caught and reported, not raised
|
||||
assert mock_client.write_gatt_char.call_count == 1
|
||||
|
||||
|
||||
def test_write_payload_bleak_error_still_returns_false():
|
||||
"""The pre-existing BleakError path must keep returning False (no regression
|
||||
from widening the except to also cover OSError)."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.write_gatt_char = AsyncMock(side_effect=BleakError("disconnected"))
|
||||
session = Session(mock_client)
|
||||
|
||||
assert _run(session.write_payload({"ok": True})) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC#3 graceful Quit: _wait_first wakes immediately on stop (clean disconnect)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_wait_first_returns_immediately_when_an_event_is_set():
|
||||
"""The poll loop's TICK wait must break the instant stop_event is set, so the
|
||||
finally: client.disconnect() runs before the process exits (SC#3). The outer
|
||||
wait_for(2s) fails fast if _wait_first wrongly blocks for the full 30s timeout."""
|
||||
async def go():
|
||||
refresh = asyncio.Event()
|
||||
stop = asyncio.Event()
|
||||
stop.set() # stop signalled
|
||||
await asyncio.wait_for(_wait_first(refresh, stop, timeout=30.0), timeout=2.0)
|
||||
assert not refresh.is_set() # loser waiter drained, refresh untouched
|
||||
_run(go())
|
||||
|
||||
|
||||
def test_wait_first_returns_after_timeout_when_no_event_set():
|
||||
"""With neither event set, _wait_first returns after `timeout` (the normal
|
||||
poll-tick path) rather than hanging."""
|
||||
async def go():
|
||||
await asyncio.wait_for(
|
||||
_wait_first(asyncio.Event(), asyncio.Event(), timeout=0.05), timeout=2.0
|
||||
)
|
||||
_run(go())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SC#5: transient poll failure must NOT toast "token expired"; only a real
|
||||
# 401/403 (AuthError) should. A boot-time DNS blip returns None, not AuthError.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _connected_mock_client():
|
||||
client = AsyncMock()
|
||||
client.connect = AsyncMock(return_value=None)
|
||||
client.is_connected = True
|
||||
client.disconnect = AsyncMock()
|
||||
client.start_notify = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
def test_transient_poll_failure_does_not_set_error():
|
||||
"""poll_api returning None (network/DNS, timeout, 5xx, 429) is transient and
|
||||
must leave the tray state untouched — not flip it to 'token expired' (SC#5
|
||||
field report: `getaddrinfo failed` at boot wrongly fired the toast)."""
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
tray_state = MagicMock()
|
||||
client = _connected_mock_client()
|
||||
|
||||
async def fake_poll(_token):
|
||||
stop_event.set() # end the loop after this single transient failure
|
||||
return None
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=client), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="tok"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api", new=fake_poll):
|
||||
_run(connect_and_run(device, stop_event, tray_state))
|
||||
|
||||
tray_state.set_error.assert_not_called()
|
||||
tray_state.set_connected.assert_not_called()
|
||||
|
||||
|
||||
def test_auth_error_sets_token_expired():
|
||||
"""A genuine 401/403 surfaces as AuthError and DOES flip the tray to the
|
||||
actionable 'token expired — run claude login' error state."""
|
||||
device = _make_device()
|
||||
stop_event = asyncio.run(_make_event(False))
|
||||
tray_state = MagicMock()
|
||||
client = _connected_mock_client()
|
||||
|
||||
async def fake_poll(_token):
|
||||
stop_event.set()
|
||||
raise AuthError(401)
|
||||
|
||||
with patch("daemon.claude_usage_daemon_windows.BleakClient", return_value=client), \
|
||||
patch("daemon.claude_usage_daemon_windows.read_token", return_value="tok"), \
|
||||
patch("daemon.claude_usage_daemon_windows.poll_api", new=fake_poll):
|
||||
_run(connect_and_run(device, stop_event, tray_state))
|
||||
|
||||
tray_state.set_error.assert_called_once_with("token expired — run claude login")
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for daemon/tray_windows.py — APP-01.
|
||||
|
||||
Covers:
|
||||
TrayState scalar setters and initial state
|
||||
header_text() for all three states including last_sync=None
|
||||
daemon main() accepts tray_state and populates ts.loop / ts.stop_event
|
||||
Quit routes through loop.call_soon_threadsafe (not stop_event.set directly)
|
||||
Error toast fires only on transition INTO error state (D-04)
|
||||
|
||||
All pystray usage is inside tray_windows.main() (deferred import), so these
|
||||
tests can import the pure helpers (TrayState, header_text) and test Quit/toast
|
||||
handlers with mocked icons without importing the GTK-less top-level pystray.
|
||||
|
||||
Run: python -m pytest daemon/tests/test_windows_tray.py -x -q
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from daemon.tray_windows import TrayState, header_text, _acquire_single_instance, _ERROR_ALREADY_EXISTS
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TrayState — initial state and setters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_tray_state_initial():
|
||||
"""TrayState initialises to scanning state with no last_sync."""
|
||||
ts = TrayState()
|
||||
assert ts.state == "scanning"
|
||||
assert ts.reason == ""
|
||||
assert ts.last_sync is None
|
||||
assert ts.loop is None
|
||||
assert ts.stop_event is None
|
||||
|
||||
|
||||
def test_set_connected():
|
||||
"""set_connected(ts_float) sets state='connected', clears reason, records last_sync."""
|
||||
ts = TrayState()
|
||||
now = time.time()
|
||||
ts.set_connected(now)
|
||||
assert ts.state == "connected"
|
||||
assert ts.reason == ""
|
||||
assert ts.last_sync == now
|
||||
|
||||
|
||||
def test_set_scanning():
|
||||
"""set_scanning() sets state='scanning', clears reason."""
|
||||
ts = TrayState()
|
||||
ts.set_error("something bad") # put it in error first
|
||||
ts.set_scanning()
|
||||
assert ts.state == "scanning"
|
||||
assert ts.reason == ""
|
||||
|
||||
|
||||
def test_set_error():
|
||||
"""set_error(why) sets state='error' and stores the reason string."""
|
||||
ts = TrayState()
|
||||
ts.set_error("token expired — run claude login")
|
||||
assert ts.state == "error"
|
||||
assert ts.reason == "token expired — run claude login"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# header_text — D-05 string shapes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_header_text_scanning():
|
||||
"""header_text returns 'Scanning…' in scanning state."""
|
||||
ts = TrayState()
|
||||
ts.set_scanning()
|
||||
assert header_text(ts) == "Scanning…"
|
||||
|
||||
|
||||
def test_header_text_error():
|
||||
"""header_text returns 'Error: {reason}' in error state."""
|
||||
ts = TrayState()
|
||||
ts.set_error("token expired — run claude login")
|
||||
result = header_text(ts)
|
||||
assert result == "Error: token expired — run claude login"
|
||||
|
||||
|
||||
def test_header_text_connected_with_last_sync():
|
||||
"""header_text returns 'Connected · last update HH:MM' when last_sync is set."""
|
||||
ts = TrayState()
|
||||
# Use a known timestamp so we can predict the HH:MM string.
|
||||
known_ts = time.mktime(time.strptime("2026-06-01 14:32:00", "%Y-%m-%d %H:%M:%S"))
|
||||
ts.set_connected(known_ts)
|
||||
result = header_text(ts)
|
||||
# Extract the HH:MM portion from the actual local time expansion.
|
||||
expected_when = time.strftime("%H:%M", time.localtime(known_ts))
|
||||
assert result == f"Connected · last update {expected_when}"
|
||||
|
||||
|
||||
def test_header_text_connected_never_when_last_sync_none():
|
||||
"""header_text returns 'Connected · last update never' when last_sync is None."""
|
||||
ts = TrayState()
|
||||
# Manually set state without using set_connected so last_sync stays None.
|
||||
ts.state = "connected"
|
||||
ts.last_sync = None
|
||||
result = header_text(ts)
|
||||
assert result == "Connected · last update never"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# daemon main() populates ts.loop and ts.stop_event
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_main_populates_tray_state_loop_and_stop_event():
|
||||
"""daemon main(tray_state=ts) sets ts.loop and ts.stop_event before the loop body."""
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
ts = TrayState()
|
||||
populated = {}
|
||||
|
||||
async def _fake_scan():
|
||||
# Record the state of ts at first scan entry (after main() startup lines).
|
||||
populated["loop"] = ts.loop
|
||||
populated["stop_event"] = ts.stop_event
|
||||
# Signal stop so the loop exits cleanly.
|
||||
ts.stop_event.set()
|
||||
return None # no device found
|
||||
|
||||
with patch.object(mod, "scan_for_device", side_effect=_fake_scan):
|
||||
asyncio.run(mod.main(tray_state=ts))
|
||||
|
||||
assert populated.get("loop") is not None, "ts.loop must be set by daemon main()"
|
||||
assert populated.get("stop_event") is not None, "ts.stop_event must be set by daemon main()"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quit handler routes through call_soon_threadsafe (not stop_event.set directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_quit_uses_call_soon_threadsafe():
|
||||
"""The Quit menu handler calls loop.call_soon_threadsafe(stop_event.set) and icon.stop().
|
||||
|
||||
It must NOT call stop_event.set() directly from the tray thread
|
||||
(RESEARCH Pitfall 2 / T-04-06 mitigation).
|
||||
"""
|
||||
# Build a TrayState with a mocked loop and stop_event.
|
||||
ts = TrayState()
|
||||
mock_loop = MagicMock()
|
||||
mock_stop_event = MagicMock()
|
||||
ts.loop = mock_loop
|
||||
ts.stop_event = mock_stop_event
|
||||
|
||||
# Build the Quit handler the same way tray_windows.main() does, without
|
||||
# importing pystray at the module level. We construct a local closure
|
||||
# that mirrors the on_quit body.
|
||||
mock_icon = MagicMock()
|
||||
|
||||
def _on_quit(icon_ref, _item):
|
||||
# This is the exact body from tray_windows.main() — keep in sync.
|
||||
ts.loop.call_soon_threadsafe(ts.stop_event.set)
|
||||
icon_ref.stop()
|
||||
|
||||
_on_quit(mock_icon, None)
|
||||
|
||||
# call_soon_threadsafe must have been called with stop_event.set as the arg.
|
||||
mock_loop.call_soon_threadsafe.assert_called_once_with(mock_stop_event.set)
|
||||
# icon.stop() must have been called.
|
||||
mock_icon.stop.assert_called_once()
|
||||
# stop_event.set() must NOT have been called directly.
|
||||
mock_stop_event.set.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error toast fires only on transition INTO error (D-04)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_error_toast_on_entry_only():
|
||||
"""The tray refresh loop fires icon.notify() only on transition INTO error.
|
||||
|
||||
Sequence: scanning -> error -> error
|
||||
Expected: notify called exactly once (on the scanning->error transition).
|
||||
"""
|
||||
ts = TrayState()
|
||||
ts.set_scanning()
|
||||
|
||||
mock_icon = MagicMock()
|
||||
mock_icon._running = True
|
||||
|
||||
# Simulate the _refresh loop's state-change detection logic from tray_windows.main().
|
||||
# We run two transitions manually:
|
||||
# 1. scanning -> error (should call notify once)
|
||||
# 2. error -> error (no change — notify must NOT fire again)
|
||||
prev_state: dict = {"state": None}
|
||||
|
||||
def _process_state_change(new_state: str, reason: str = "") -> None:
|
||||
"""Mirror the relevant part of the _refresh loop body."""
|
||||
ts.state = new_state
|
||||
ts.reason = reason
|
||||
current = ts.state
|
||||
if current != prev_state["state"]:
|
||||
if current == "error" and prev_state["state"] != "error":
|
||||
mock_icon.notify(ts.reason or "Clawdmeter error", "Clawdmeter")
|
||||
prev_state["state"] = current
|
||||
|
||||
# Transition 1: scanning -> error (notify should fire)
|
||||
_process_state_change("scanning")
|
||||
_process_state_change("error", "token expired — run claude login")
|
||||
# Transition 2: error -> error (same state — no call)
|
||||
_process_state_change("error", "token expired — run claude login")
|
||||
|
||||
mock_icon.notify.assert_called_once_with(
|
||||
"token expired — run claude login", "Clawdmeter"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-instance guard (named mutex) — duplicate-launch / ARSO collision
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field bug: Windows "restart apps after sign-in" (ARSO) restored a console
|
||||
# `python.exe tray_windows.py` instance while the headless `pythonw` autostart
|
||||
# also fired — two trays fighting over the one BLE link. The guard makes a
|
||||
# second instance exit before it touches BLE.
|
||||
|
||||
def test_single_instance_noop_off_windows():
|
||||
"""Off-Windows the guard is a no-op that returns a truthy sentinel (never None)."""
|
||||
with patch("daemon.tray_windows.sys") as mock_sys:
|
||||
mock_sys.platform = "linux"
|
||||
assert _acquire_single_instance() is not None
|
||||
|
||||
|
||||
def _fake_kernel32(last_error: int, handle: int):
|
||||
"""Build a fake ctypes module tree whose CreateMutexW returns `handle` and
|
||||
whose get_last_error() returns `last_error`."""
|
||||
fake_kernel32 = MagicMock()
|
||||
fake_kernel32.CreateMutexW.return_value = handle
|
||||
fake_ctypes = MagicMock()
|
||||
fake_ctypes.WinDLL.return_value = fake_kernel32
|
||||
fake_ctypes.get_last_error.return_value = last_error
|
||||
return fake_ctypes
|
||||
|
||||
|
||||
def test_single_instance_first_instance_gets_handle():
|
||||
"""First instance: CreateMutexW succeeds, no prior owner → returns the handle."""
|
||||
fake_ctypes = _fake_kernel32(last_error=0, handle=0xABCD)
|
||||
with patch("daemon.tray_windows.sys") as mock_sys, \
|
||||
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
||||
mock_sys.platform = "win32"
|
||||
assert _acquire_single_instance() == 0xABCD
|
||||
|
||||
|
||||
def test_single_instance_second_instance_gets_none():
|
||||
"""Second instance: mutex already exists → returns None so caller exits."""
|
||||
fake_ctypes = _fake_kernel32(last_error=_ERROR_ALREADY_EXISTS, handle=0xABCD)
|
||||
with patch("daemon.tray_windows.sys") as mock_sys, \
|
||||
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
||||
mock_sys.platform = "win32"
|
||||
assert _acquire_single_instance() is None
|
||||
|
||||
|
||||
def test_single_instance_fails_open_on_null_handle():
|
||||
"""If CreateMutexW returns NULL, fail OPEN (truthy) — never block tray startup."""
|
||||
fake_ctypes = _fake_kernel32(last_error=_ERROR_ALREADY_EXISTS, handle=0)
|
||||
with patch("daemon.tray_windows.sys") as mock_sys, \
|
||||
patch.dict("sys.modules", {"ctypes": fake_ctypes, "ctypes.wintypes": MagicMock()}):
|
||||
mock_sys.platform = "win32"
|
||||
result = _acquire_single_instance()
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: cwd-independent package + asset resolution (SC#1 logon autostart)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field bug: launching `pythonw.exe daemon\tray_windows.py` at logon starts with
|
||||
# cwd = System32, so `import daemon.*` raised ModuleNotFoundError and the relative
|
||||
# logo path failed — the tray crashed silently with no icon. tray_windows must
|
||||
# self-locate the repo root from __file__ so it works from any working directory.
|
||||
|
||||
def test_repo_root_is_parent_of_daemon_package():
|
||||
"""_REPO_ROOT points at the dir that CONTAINS the daemon package."""
|
||||
import os
|
||||
import daemon.tray_windows as tw
|
||||
|
||||
assert os.path.isdir(os.path.join(tw._REPO_ROOT, "daemon"))
|
||||
assert os.path.isfile(
|
||||
os.path.join(tw._REPO_ROOT, "firmware", "src", "logo.h")
|
||||
), "brand logo must resolve from _REPO_ROOT, not the current working directory"
|
||||
|
||||
|
||||
def test_repo_root_on_sys_path_after_import():
|
||||
"""Importing tray_windows puts the repo root on sys.path so `daemon.*` resolves regardless of cwd."""
|
||||
import sys
|
||||
import daemon.tray_windows as tw
|
||||
|
||||
assert tw._REPO_ROOT in sys.path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression: daemon main() must run in a BACKGROUND thread (SC#1 tray launch)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Field bug: under the tray the loop runs in threading.Thread (pystray owns the
|
||||
# main thread). OS signal-handler installation (loop.add_signal_handler /
|
||||
# signal.signal) only works on the main thread, so main() raised
|
||||
# "signal only works in main thread" and the daemon thread died on startup.
|
||||
# main() must guard signal setup to the main thread; the tray owns shutdown.
|
||||
|
||||
def test_main_runs_in_background_thread_without_signal_error():
|
||||
"""main(tray_state=ts) started from a non-main thread must not raise on signal setup."""
|
||||
import threading as _threading
|
||||
import daemon.claude_usage_daemon_windows as mod
|
||||
|
||||
ts = TrayState()
|
||||
errors: list = []
|
||||
|
||||
async def _fake_scan():
|
||||
ts.stop_event.set() # exit the loop immediately
|
||||
return None
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
with patch.object(mod, "scan_for_device", side_effect=_fake_scan):
|
||||
asyncio.run(mod.main(tray_state=ts))
|
||||
except Exception as exc: # noqa: BLE001 — capture for the assertion
|
||||
errors.append(exc)
|
||||
|
||||
t = _threading.Thread(target=_run)
|
||||
t.start()
|
||||
t.join(timeout=10)
|
||||
|
||||
assert not t.is_alive(), "daemon main() hung in background thread"
|
||||
assert not errors, f"main() raised in a background thread: {errors!r}"
|
||||
Reference in New Issue
Block a user