"""Unit tests for the foundry watch CLI command.
Tests cover:
- Data assembly resolves session and loads state
- Error when no active session
- Simple mode outputs formatted event lines
- Terminal state detection exits the loop
- Stop signal written via 's' key in live dashboard
"""
import json
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from foundry_mcp.cli.main import cli
@pytest.fixture
def temp_specs_dir(tmp_path):
"""Create a temporary specs directory with a resolvable spec."""
specs_dir = tmp_path / "specs"
active_dir = specs_dir / "active"
active_dir.mkdir(parents=True)
test_spec = {
"id": "test-spec-001",
"title": "Test Specification",
"version": "1.0.0",
"status": "active",
"hierarchy": {
"spec-root": {
"type": "root",
"title": "Test",
"children": ["phase-1"],
"status": "in_progress",
},
"phase-1": {
"type": "phase",
"title": "Phase 1",
"parent": "spec-root",
"children": ["task-1-1"],
"status": "in_progress",
},
"task-1-1": {
"type": "task",
"title": "Task 1",
"parent": "phase-1",
"status": "completed",
"metadata": {},
"dependencies": {},
},
},
"journal": [],
}
spec_file = active_dir / "test-spec-001.json"
spec_file.write_text(json.dumps(test_spec, indent=2))
return specs_dir
def _make_mock_session(status="running", active_phase="phase-1", task_id="task-1-1"):
"""Create a mock session object with required attributes."""
session = MagicMock()
session.id = "session-abc-123"
session.spec_id = "test-spec-001"
session.status.value = status
session.active_phase_id = active_phase
session.last_task_id = task_id
session.context.context_usage_pct = 42
session.context.last_heartbeat_at = datetime(2026, 2, 18, 12, 0, 0, tzinfo=timezone.utc)
session.last_step_issued = None
session.counters.tasks_completed = 3
session.counters.consecutive_errors = 0
session.model_dump.return_value = {
"id": "session-abc-123",
"spec_id": "test-spec-001",
"status": status,
"pause_reason": None,
"created_at": "2026-02-18T10:00:00Z",
"updated_at": "2026-02-18T12:00:00Z",
}
return session
def _make_mock_event(seq=1, event_type="step_issued", task_id="task-1-1", action="implement"):
"""Create a mock audit event."""
evt = MagicMock()
evt.sequence = seq
evt.timestamp = "2026-02-18T12:00:00Z"
evt.event_type.value = event_type
evt.action = action
evt.task_id = task_id
evt.phase_id = None
return evt
class TestDataAssembly:
"""Tests for _assemble_watch_data resolving session and loading state."""
@patch("foundry_mcp.core.autonomy.audit.AuditLedger")
@patch("foundry_mcp.core.autonomy.memory.AutonomyStorage")
def test_assembles_session_and_progress(self, mock_storage_cls, mock_ledger_cls, temp_specs_dir):
"""Data assembly resolves session ID, loads session, audit, progress."""
from foundry_mcp.cli.commands.watch import _assemble_watch_data
mock_storage = MagicMock()
mock_storage_cls.return_value = mock_storage
mock_storage.get_active_session.return_value = "session-abc-123"
session = _make_mock_session()
mock_storage.load.return_value = session
mock_ledger = MagicMock()
mock_ledger_cls.return_value = mock_ledger
evt = _make_mock_event()
mock_ledger.get_entries.return_value = [evt]
data = _assemble_watch_data(temp_specs_dir, "test-spec-001", max_events=10)
assert data["session"]["id"] == "session-abc-123"
assert data["session"]["status"] == "running"
assert data["session"]["active_phase_id"] == "phase-1"
assert data["session"]["context_usage_pct"] == 42
assert data["session"]["tasks_completed"] == 3
assert len(data["audit_events"]) == 1
assert data["audit_events"][0]["event_type"] == "step_issued"
assert data["progress"]["total_tasks"] > 0
class TestNoActiveSession:
"""Tests for error when no active session found."""
def test_error_when_no_active_session(self, cli_runner, temp_specs_dir):
"""watch returns error when no active session exists."""
with patch("foundry_mcp.core.autonomy.memory.AutonomyStorage") as mock_storage_cls:
mock_storage = MagicMock()
mock_storage_cls.return_value = mock_storage
mock_storage.get_active_session.return_value = None
result = cli_runner.invoke(
cli,
[
"--specs-dir",
str(temp_specs_dir),
"watch",
"--simple",
"test-spec-001",
],
)
assert result.exit_code == 1
data = json.loads(result.output)
assert data["success"] is False
assert data["data"]["error_code"] == "NOT_FOUND"
class TestSimpleModeOutput:
"""Tests for --simple mode output format."""
@patch("foundry_mcp.cli.commands.watch.time.sleep")
@patch("foundry_mcp.core.autonomy.audit.AuditLedger")
@patch("foundry_mcp.core.autonomy.memory.AutonomyStorage")
def test_simple_mode_prints_header_and_events(
self, mock_storage_cls, mock_ledger_cls, mock_sleep, cli_runner, temp_specs_dir
):
"""--simple prints header line and formatted event lines."""
mock_storage = MagicMock()
mock_storage_cls.return_value = mock_storage
mock_storage.get_active_session.return_value = "session-abc-123"
# First load returns running, second returns paused (to exit loop)
session_running = _make_mock_session(status="running")
session_paused = _make_mock_session(status="paused")
mock_storage.load.side_effect = [session_running, session_paused]
mock_ledger = MagicMock()
mock_ledger_cls.return_value = mock_ledger
evt = _make_mock_event(seq=1, event_type="step_issued", task_id="task-1-1", action="implement")
mock_ledger.get_entries.return_value = [evt]
result = cli_runner.invoke(
cli,
[
"--specs-dir",
str(temp_specs_dir),
"watch",
"--simple",
"test-spec-001",
],
)
assert result.exit_code == 0, f"Output: {result.output}"
lines = result.output.strip().split("\n")
# Header line
assert "session=session-abc-123" in lines[0]
assert "spec=test-spec-001" in lines[0]
assert "status=running" in lines[0]
# Event line
assert "step_issued" in result.output
assert "implement" in result.output
# Terminal message
assert "--- session paused ---" in result.output
class TestTerminalStateExit:
"""Tests for terminal state detection exiting the loop."""
@patch("foundry_mcp.core.autonomy.audit.AuditLedger")
@patch("foundry_mcp.core.autonomy.memory.AutonomyStorage")
def test_simple_mode_exits_on_completed(self, mock_storage_cls, mock_ledger_cls, cli_runner, temp_specs_dir):
"""--simple exits immediately when session is already completed."""
mock_storage = MagicMock()
mock_storage_cls.return_value = mock_storage
mock_storage.get_active_session.return_value = "session-abc-123"
session = _make_mock_session(status="completed")
mock_storage.load.return_value = session
mock_ledger = MagicMock()
mock_ledger_cls.return_value = mock_ledger
mock_ledger.get_entries.return_value = []
result = cli_runner.invoke(
cli,
[
"--specs-dir",
str(temp_specs_dir),
"watch",
"--simple",
"test-spec-001",
],
)
assert result.exit_code == 0
assert "--- session completed ---" in result.output
@patch("foundry_mcp.core.autonomy.audit.AuditLedger")
@patch("foundry_mcp.core.autonomy.memory.AutonomyStorage")
def test_simple_mode_exits_on_failed(self, mock_storage_cls, mock_ledger_cls, cli_runner, temp_specs_dir):
"""--simple exits immediately when session is failed."""
mock_storage = MagicMock()
mock_storage_cls.return_value = mock_storage
mock_storage.get_active_session.return_value = "session-abc-123"
session = _make_mock_session(status="failed")
mock_storage.load.return_value = session
mock_ledger = MagicMock()
mock_ledger_cls.return_value = mock_ledger
mock_ledger.get_entries.return_value = []
result = cli_runner.invoke(
cli,
[
"--specs-dir",
str(temp_specs_dir),
"watch",
"--simple",
"test-spec-001",
],
)
assert result.exit_code == 0
assert "--- session failed ---" in result.output
class TestWriteStopSignal:
"""Tests for _write_stop_signal writing the shared signal file."""
def test_write_stop_signal_creates_file(self, tmp_path):
"""_write_stop_signal creates a signal file via the shared utility."""
from foundry_mcp.cli.commands.watch import _write_stop_signal
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
result_path = _write_stop_signal(specs_dir, "test-spec-001")
assert result_path.exists()
content = json.loads(result_path.read_text())
assert content["requested_by"] == "foundry-watch"
assert content["reason"] == "operator_stop"
def test_write_stop_signal_path_matches_convention(self, tmp_path):
"""Signal file is written at the canonical path."""
from foundry_mcp.cli.commands.watch import _write_stop_signal
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
result_path = _write_stop_signal(specs_dir, "my-spec")
expected = specs_dir / ".autonomy" / "signals" / "my-spec.stop"
assert result_path == expected
class TestSignalIntegration:
"""Integration test: signal written by CLI is consumed by orchestrator."""
def test_cli_signal_consumed_by_orchestrator(self, tmp_path):
"""Signal file written by write_stop_signal triggers orchestrator pause."""
from foundry_mcp.core.autonomy.signals import write_stop_signal
# Set up workspace with specs dir and a signal file
specs_dir = tmp_path / "specs"
active_dir = specs_dir / "active"
active_dir.mkdir(parents=True)
spec_data = {
"id": "test-spec-001",
"title": "Test",
"version": "1.0.0",
"status": "active",
"hierarchy": {
"spec-root": {
"type": "root",
"title": "Test",
"children": ["phase-1"],
"status": "in_progress",
},
"phase-1": {
"type": "phase",
"title": "Phase 1",
"parent": "spec-root",
"children": ["task-1"],
"status": "in_progress",
},
"task-1": {
"type": "task",
"title": "Task 1",
"parent": "phase-1",
"status": "pending",
"metadata": {},
"dependencies": {},
},
},
"journal": [],
}
spec_file = active_dir / "test-spec-001.json"
spec_file.write_text(json.dumps(spec_data, indent=2))
# Write signal via shared utility (same code path as stop/watch CLI)
signal_file = write_stop_signal(specs_dir, "test-spec-001", requested_by="foundry-test")
assert signal_file.exists()
# Verify the orchestrator's _check_stop_signal finds and consumes it
from foundry_mcp.core.autonomy.signals import signal_path_for_spec
orch_signal = signal_path_for_spec(specs_dir, "test-spec-001")
assert orch_signal == signal_file
assert orch_signal.is_file()
# Consuming the signal (as orchestrator would)
orch_signal.unlink()
assert not signal_file.exists()