forked from molecule-ai/molecule-core
Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
407 lines
13 KiB
Python
407 lines
13 KiB
Python
"""Tests for watcher.py — ConfigWatcher polling, hashing, and change detection."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import os
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch, call
|
|
|
|
import pytest
|
|
|
|
from watcher import ConfigWatcher, POLL_INTERVAL, DEBOUNCE_SECONDS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture
|
|
def tmp_config(tmp_path):
|
|
"""Return a temporary config directory path (string)."""
|
|
return str(tmp_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def watcher(tmp_config):
|
|
"""Return a ConfigWatcher pointed at a temporary config directory."""
|
|
return ConfigWatcher(
|
|
config_path=tmp_config,
|
|
platform_url="http://platform:8080",
|
|
workspace_id="ws-test",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# __init__
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_init_stores_attrs(tmp_config):
|
|
"""Constructor stores all provided arguments as attributes."""
|
|
cb = MagicMock()
|
|
w = ConfigWatcher(
|
|
config_path=tmp_config,
|
|
platform_url="http://platform:8080",
|
|
workspace_id="ws-42",
|
|
on_reload=cb,
|
|
)
|
|
assert w.config_path == tmp_config
|
|
assert w.platform_url == "http://platform:8080"
|
|
assert w.workspace_id == "ws-42"
|
|
assert w.on_reload is cb
|
|
assert w._file_hashes == {}
|
|
assert w._running is False
|
|
|
|
|
|
def test_init_defaults_on_reload(tmp_config):
|
|
"""on_reload defaults to None when not supplied."""
|
|
w = ConfigWatcher(tmp_config, "http://p:8080", "ws-1")
|
|
assert w.on_reload is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _hash_file
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_hash_file_real_file(tmp_path, watcher):
|
|
"""_hash_file returns sha256 hex digest of the file's bytes."""
|
|
f = tmp_path / "sample.txt"
|
|
f.write_bytes(b"hello world")
|
|
expected = hashlib.sha256(b"hello world").hexdigest()
|
|
assert watcher._hash_file(str(f)) == expected
|
|
|
|
|
|
def test_hash_file_missing_returns_empty(watcher):
|
|
"""_hash_file returns '' for a non-existent file (OSError path)."""
|
|
result = watcher._hash_file("/nonexistent/path/file.txt")
|
|
assert result == ""
|
|
|
|
|
|
def test_hash_file_ioerror(tmp_path, watcher, monkeypatch):
|
|
"""_hash_file returns '' when Path.read_bytes raises IOError."""
|
|
f = tmp_path / "bad.txt"
|
|
f.write_bytes(b"data")
|
|
monkeypatch.setattr(Path, "read_bytes", lambda self: (_ for _ in ()).throw(IOError("read error")))
|
|
assert watcher._hash_file(str(f)) == ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _scan_hashes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_scan_hashes_empty_directory(watcher):
|
|
"""_scan_hashes returns an empty dict for an empty config dir."""
|
|
result = watcher._scan_hashes()
|
|
assert result == {}
|
|
|
|
|
|
def test_scan_hashes_skips_dotfiles(tmp_path, watcher):
|
|
"""_scan_hashes ignores files whose names start with '.'."""
|
|
(tmp_path / ".hidden").write_bytes(b"secret")
|
|
(tmp_path / "visible.yaml").write_bytes(b"data: 1")
|
|
result = watcher._scan_hashes()
|
|
keys = list(result.keys())
|
|
assert all(not k.startswith(".") for k in keys)
|
|
assert any("visible.yaml" in k for k in keys)
|
|
|
|
|
|
def test_scan_hashes_returns_relative_paths(tmp_path, watcher):
|
|
"""_scan_hashes keys are relative to config_path, not absolute."""
|
|
(tmp_path / "config.yaml").write_bytes(b"key: value")
|
|
result = watcher._scan_hashes()
|
|
assert "config.yaml" in result
|
|
|
|
|
|
def test_scan_hashes_subdirectory(tmp_path, watcher):
|
|
"""_scan_hashes recurses into subdirectories."""
|
|
sub = tmp_path / "subdir"
|
|
sub.mkdir()
|
|
(sub / "nested.json").write_bytes(b"{}")
|
|
result = watcher._scan_hashes()
|
|
# relative path should be like "subdir/nested.json" or "subdir\\nested.json"
|
|
assert any("nested.json" in k for k in result.keys())
|
|
|
|
|
|
def test_scan_hashes_multiple_files(tmp_path, watcher):
|
|
"""_scan_hashes captures all non-hidden files in the directory."""
|
|
for name in ("a.yaml", "b.yaml", "c.json"):
|
|
(tmp_path / name).write_bytes(name.encode())
|
|
result = watcher._scan_hashes()
|
|
assert len(result) == 3
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _detect_changes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_detect_changes_no_changes(tmp_path, watcher):
|
|
"""_detect_changes returns an empty list when nothing changed."""
|
|
(tmp_path / "file.yaml").write_bytes(b"content")
|
|
# Seed the hashes
|
|
watcher._file_hashes = watcher._scan_hashes()
|
|
changed = watcher._detect_changes()
|
|
assert changed == []
|
|
|
|
|
|
def test_detect_changes_new_file(tmp_path, watcher):
|
|
"""_detect_changes reports a file that appeared since last scan."""
|
|
watcher._file_hashes = {}
|
|
(tmp_path / "new.yaml").write_bytes(b"new")
|
|
changed = watcher._detect_changes()
|
|
assert any("new.yaml" in p for p in changed)
|
|
|
|
|
|
def test_detect_changes_modified_file(tmp_path, watcher):
|
|
"""_detect_changes reports a file whose content has changed."""
|
|
f = tmp_path / "mod.yaml"
|
|
f.write_bytes(b"original")
|
|
watcher._file_hashes = watcher._scan_hashes()
|
|
# Modify the file
|
|
f.write_bytes(b"modified")
|
|
changed = watcher._detect_changes()
|
|
assert any("mod.yaml" in p for p in changed)
|
|
|
|
|
|
def test_detect_changes_deleted_file(tmp_path, watcher):
|
|
"""_detect_changes reports a file that was deleted since last scan."""
|
|
f = tmp_path / "gone.yaml"
|
|
f.write_bytes(b"was here")
|
|
watcher._file_hashes = watcher._scan_hashes()
|
|
# Delete the file
|
|
f.unlink()
|
|
changed = watcher._detect_changes()
|
|
assert any("gone.yaml" in p for p in changed)
|
|
|
|
|
|
def test_detect_changes_updates_cached_hashes(tmp_path, watcher):
|
|
"""After _detect_changes, _file_hashes reflects the current state."""
|
|
f = tmp_path / "track.yaml"
|
|
f.write_bytes(b"v1")
|
|
watcher._file_hashes = {}
|
|
watcher._detect_changes()
|
|
assert any("track.yaml" in k for k in watcher._file_hashes)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _notify_platform
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_platform_success(watcher):
|
|
"""_notify_platform POSTs the agent card to the correct URL."""
|
|
mock_response = MagicMock()
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(return_value=mock_response)
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
with patch("watcher.httpx.AsyncClient", return_value=mock_client):
|
|
await watcher._notify_platform({"name": "MyAgent"})
|
|
|
|
mock_client.post.assert_called_once()
|
|
call_args = mock_client.post.call_args
|
|
assert call_args[0][0] == "http://platform:8080/registry/update-card"
|
|
payload = call_args[1]["json"]
|
|
assert payload["workspace_id"] == "ws-test"
|
|
assert payload["agent_card"] == {"name": "MyAgent"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_notify_platform_failure_logs_warning(watcher, caplog):
|
|
"""_notify_platform logs a warning when the POST raises an exception."""
|
|
mock_client = AsyncMock()
|
|
mock_client.post = AsyncMock(side_effect=Exception("connection refused"))
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
import logging
|
|
with patch("watcher.httpx.AsyncClient", return_value=mock_client):
|
|
with caplog.at_level(logging.WARNING, logger="watcher"):
|
|
await watcher._notify_platform({})
|
|
|
|
assert "Failed to update Agent Card" in caplog.text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# start() / stop()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_sets_running_false(watcher):
|
|
"""stop() sets _running to False."""
|
|
watcher._running = True
|
|
watcher.stop()
|
|
assert watcher._running is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_sets_running_true_and_seeds_hashes(tmp_path, watcher):
|
|
"""start() sets _running=True and seeds _file_hashes before looping."""
|
|
(tmp_path / "seed.yaml").write_bytes(b"data")
|
|
|
|
sleep_calls = []
|
|
|
|
async def fake_sleep(secs):
|
|
sleep_calls.append(secs)
|
|
# Stop after the first POLL_INTERVAL sleep
|
|
watcher._running = False
|
|
|
|
with patch("watcher.asyncio.sleep", side_effect=fake_sleep):
|
|
await watcher.start()
|
|
|
|
assert any("seed.yaml" in k for k in watcher._file_hashes)
|
|
# First sleep should be POLL_INTERVAL
|
|
assert sleep_calls[0] == POLL_INTERVAL
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_no_changes_continues_loop(tmp_path, watcher):
|
|
"""When no files change, the loop iterates without calling debounce sleep."""
|
|
(tmp_path / "stable.yaml").write_bytes(b"stable")
|
|
|
|
iteration = [0]
|
|
|
|
async def fake_sleep(secs):
|
|
iteration[0] += 1
|
|
if iteration[0] >= 2:
|
|
watcher._running = False
|
|
|
|
with patch("watcher.asyncio.sleep", side_effect=fake_sleep):
|
|
await watcher.start()
|
|
|
|
# Should have slept twice (both POLL_INTERVAL), no DEBOUNCE sleep
|
|
assert iteration[0] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_detects_change_and_debounces(tmp_path, watcher):
|
|
"""When changes are detected, start() sleeps for DEBOUNCE_SECONDS too."""
|
|
(tmp_path / "change.yaml").write_bytes(b"v1")
|
|
|
|
sleep_calls = []
|
|
call_count = [0]
|
|
|
|
async def fake_sleep(secs):
|
|
sleep_calls.append(secs)
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
# After POLL_INTERVAL sleep, modify the file to trigger a change
|
|
(tmp_path / "change.yaml").write_bytes(b"v2")
|
|
elif call_count[0] >= 2:
|
|
# After DEBOUNCE sleep, stop
|
|
watcher._running = False
|
|
|
|
with patch("watcher.asyncio.sleep", side_effect=fake_sleep):
|
|
await watcher.start()
|
|
|
|
assert POLL_INTERVAL in sleep_calls
|
|
assert DEBOUNCE_SECONDS in sleep_calls
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_calls_on_reload_callback(tmp_path):
|
|
"""start() invokes on_reload callback when changes are detected."""
|
|
reload_called = []
|
|
|
|
async def on_reload():
|
|
reload_called.append(True)
|
|
|
|
w = ConfigWatcher(
|
|
config_path=str(tmp_path),
|
|
platform_url="http://p:8080",
|
|
workspace_id="ws-1",
|
|
on_reload=on_reload,
|
|
)
|
|
|
|
(tmp_path / "watched.yaml").write_bytes(b"initial")
|
|
|
|
call_count = [0]
|
|
|
|
async def fake_sleep(secs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
# Trigger a change on first POLL_INTERVAL sleep
|
|
(tmp_path / "watched.yaml").write_bytes(b"changed")
|
|
elif call_count[0] >= 2:
|
|
w._running = False
|
|
|
|
with patch("watcher.asyncio.sleep", side_effect=fake_sleep):
|
|
await w.start()
|
|
|
|
assert reload_called, "on_reload should have been called"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_on_reload_exception_logged(tmp_path, caplog):
|
|
"""start() logs an error when on_reload callback raises an exception."""
|
|
import logging
|
|
|
|
async def bad_reload():
|
|
raise RuntimeError("reload exploded")
|
|
|
|
w = ConfigWatcher(
|
|
config_path=str(tmp_path),
|
|
platform_url="http://p:8080",
|
|
workspace_id="ws-1",
|
|
on_reload=bad_reload,
|
|
)
|
|
|
|
(tmp_path / "trigger.yaml").write_bytes(b"before")
|
|
|
|
call_count = [0]
|
|
|
|
async def fake_sleep(secs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
(tmp_path / "trigger.yaml").write_bytes(b"after")
|
|
elif call_count[0] >= 2:
|
|
w._running = False
|
|
|
|
with patch("watcher.asyncio.sleep", side_effect=fake_sleep):
|
|
with caplog.at_level(logging.ERROR, logger="watcher"):
|
|
await w.start()
|
|
|
|
assert "Reload callback failed" in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_without_on_reload_no_error(tmp_path):
|
|
"""start() handles changes gracefully even when on_reload is None."""
|
|
w = ConfigWatcher(
|
|
config_path=str(tmp_path),
|
|
platform_url="http://p:8080",
|
|
workspace_id="ws-1",
|
|
on_reload=None,
|
|
)
|
|
|
|
(tmp_path / "file.yaml").write_bytes(b"v1")
|
|
|
|
call_count = [0]
|
|
|
|
async def fake_sleep(secs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 1:
|
|
(tmp_path / "file.yaml").write_bytes(b"v2")
|
|
elif call_count[0] >= 2:
|
|
w._running = False
|
|
|
|
with patch("watcher.asyncio.sleep", side_effect=fake_sleep):
|
|
await w.start() # Should not raise
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_logs_on_startup(tmp_path, caplog):
|
|
"""start() logs an info message naming the config_path."""
|
|
import logging
|
|
|
|
w = ConfigWatcher(str(tmp_path), "http://p:8080", "ws-1")
|
|
|
|
async def fake_sleep(secs):
|
|
w._running = False
|
|
|
|
with patch("watcher.asyncio.sleep", side_effect=fake_sleep):
|
|
with caplog.at_level(logging.INFO, logger="watcher"):
|
|
await w.start()
|
|
|
|
assert "Config watcher started" in caplog.text
|