"""Unit tests for the foundry stop CLI command.
Tests cover:
- Signal file creation with correct JSON payload
- Error when spec not found
- --force mode process kill (mocked subprocess/os.kill)
- --wait mode poll and exit on terminal status (mocked AutonomyStorage)
- --wait mode timeout
"""
import json
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from foundry_mcp.cli.main import cli
class TestStopSignalFileCreation:
"""Tests for signal file creation with correct JSON payload."""
def test_signal_file_created(self, cli_runner, temp_specs_dir):
"""stop writes a signal file with correct JSON payload."""
result = cli_runner.invoke(
cli,
["--specs-dir", str(temp_specs_dir), "stop", "test-spec-001"],
)
assert result.exit_code == 0, f"Unexpected output: {result.output}"
data = json.loads(result.output)
assert data["success"] is True
assert data["data"]["spec_id"] == "test-spec-001"
assert data["data"]["action"] == "stop_requested"
# Verify signal file exists and has correct payload
signal_file = temp_specs_dir / ".autonomy" / "signals" / "test-spec-001.stop"
assert signal_file.exists()
payload = json.loads(signal_file.read_text())
assert payload["requested_by"] == "foundry-cli"
assert payload["reason"] == "operator_stop"
assert "requested_at" in payload
def test_signal_file_message(self, cli_runner, temp_specs_dir):
"""stop emits human-readable message about signal file."""
result = cli_runner.invoke(
cli,
["--specs-dir", str(temp_specs_dir), "stop", "test-spec-001"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert "signal_file" in data["data"]
assert "test-spec-001.stop" in data["data"]["signal_file"]
class TestStopNoActiveSession:
"""Tests for error when spec not found."""
def test_error_when_spec_not_found(self, cli_runner, temp_specs_dir):
"""stop returns error for non-existent spec."""
result = cli_runner.invoke(
cli,
["--specs-dir", str(temp_specs_dir), "stop", "nonexistent-spec"],
)
assert result.exit_code == 1
data = json.loads(result.output)
assert data["success"] is False
assert data["data"]["error_code"] == "NOT_FOUND"
class TestStopForceMode:
"""Tests for --force mode process kill."""
@patch("foundry_mcp.cli.commands.stop.subprocess.run")
@patch("foundry_mcp.cli.commands.stop.os.kill")
def test_force_kills_matching_processes(self, mock_kill, mock_run, cli_runner, temp_specs_dir):
"""--force finds processes via pgrep and sends SIGTERM."""
mock_run.return_value = SimpleNamespace(returncode=0, stdout="1234\n5678\n", stderr="")
result = cli_runner.invoke(
cli,
["--specs-dir", str(temp_specs_dir), "stop", "--force", "test-spec-001"],
)
assert result.exit_code == 0, f"Unexpected output: {result.output}"
data = json.loads(result.output)
assert data["data"]["action"] == "force_stop"
assert 1234 in data["data"]["killed_pids"]
assert 5678 in data["data"]["killed_pids"]
assert mock_kill.call_count == 2
@patch("foundry_mcp.cli.commands.stop.subprocess.run")
def test_force_no_matching_processes(self, mock_run, cli_runner, temp_specs_dir):
"""--force with no matching processes returns empty killed list."""
mock_run.return_value = SimpleNamespace(returncode=1, stdout="", stderr="")
result = cli_runner.invoke(
cli,
["--specs-dir", str(temp_specs_dir), "stop", "--force", "test-spec-001"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["data"]["action"] == "force_stop"
assert data["data"]["killed_pids"] == []
class TestStopForceRegexEscaping:
"""Tests for regex escaping in --force mode pgrep pattern."""
@patch("foundry_mcp.cli.commands.stop.subprocess.run")
def test_pgrep_pattern_escapes_regex_metacharacters(self, mock_run, cli_runner, temp_specs_dir):
"""--force escapes regex metacharacters in spec_id for pgrep -f."""
import re
# Create a spec with regex metacharacters in the name
active_dir = temp_specs_dir / "active"
tricky_id = "spec.with+regex*chars"
spec_data = {
"id": tricky_id,
"title": "Tricky",
"version": "1.0.0",
"status": "active",
"hierarchy": {"spec-root": {"type": "root", "title": "T", "children": [], "status": "in_progress"}},
"journal": [],
}
(active_dir / f"{tricky_id}.json").write_text(json.dumps(spec_data))
# pgrep returns no matches
mock_run.return_value = SimpleNamespace(returncode=1, stdout="", stderr="")
result = cli_runner.invoke(
cli,
["--specs-dir", str(temp_specs_dir), "stop", "--force", tricky_id],
)
assert result.exit_code == 0
# Check that the pgrep call used re.escape(spec_id)
pgrep_call = mock_run.call_args_list[0]
pattern = pgrep_call[0][0][2] # ["pgrep", "-f", <pattern>]
assert re.escape(tricky_id) in pattern
class TestStopWaitMode:
"""Tests for --wait mode poll and exit on terminal status."""
@patch("foundry_mcp.cli.commands.stop.time.sleep")
@patch("foundry_mcp.core.autonomy.memory.AutonomyStorage")
def test_wait_exits_on_terminal_status(self, mock_storage_cls, mock_sleep, cli_runner, temp_specs_dir):
"""--wait polls and exits when session reaches terminal status."""
mock_storage = MagicMock()
mock_storage_cls.return_value = mock_storage
mock_storage.get_active_session.return_value = "session-123"
mock_session = MagicMock()
mock_session.status.value = "paused"
mock_storage.load.return_value = mock_session
result = cli_runner.invoke(
cli,
["--specs-dir", str(temp_specs_dir), "stop", "--wait", "test-spec-001"],
)
assert result.exit_code == 0, f"Unexpected output: {result.output}"
data = json.loads(result.output)
assert data["data"]["action"] == "stopped"
assert data["data"]["final_status"] == "paused"
@patch("foundry_mcp.cli.commands.stop.time.sleep")
@patch("foundry_mcp.core.autonomy.memory.AutonomyStorage")
def test_wait_exits_when_no_active_session(self, mock_storage_cls, mock_sleep, cli_runner, temp_specs_dir):
"""--wait returns no_active_session when session is gone."""
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), "stop", "--wait", "test-spec-001"],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["data"]["action"] == "stopped"
assert data["data"]["final_status"] == "no_active_session"
class TestStopWaitTimeout:
"""Tests for --wait mode timeout."""
@patch("foundry_mcp.cli.commands.stop.time.sleep")
@patch("foundry_mcp.cli.commands.stop.time.monotonic")
@patch("foundry_mcp.core.autonomy.memory.AutonomyStorage")
def test_wait_times_out(self, mock_storage_cls, mock_monotonic, mock_sleep, cli_runner, temp_specs_dir):
"""--wait returns timeout when deadline is exceeded."""
mock_storage = MagicMock()
mock_storage_cls.return_value = mock_storage
mock_storage.get_active_session.return_value = "session-123"
mock_session = MagicMock()
mock_session.status.value = "running"
mock_storage.load.return_value = mock_session
# Simulate time passing: first call sets deadline, second exceeds it
mock_monotonic.side_effect = [0.0, 200.0]
result = cli_runner.invoke(
cli,
[
"--specs-dir",
str(temp_specs_dir),
"stop",
"--wait",
"--timeout",
"5",
"test-spec-001",
],
)
assert result.exit_code == 0
data = json.loads(result.output)
assert data["data"]["action"] == "timeout"
assert data["data"]["final_status"] is None
assert "Timed out" in data["data"]["message"]