370 lines
15 KiB
Python
370 lines
15 KiB
Python
import os
|
|
import subprocess
|
|
import json
|
|
import hmac
|
|
import hashlib
|
|
import shlex
|
|
import sys
|
|
import pytest
|
|
|
|
# Helper to run bash snippets sourcing lib.sh
|
|
def run_lib_func(mam_sandbox, func_name, *args, env=None):
|
|
lib_path = mam_sandbox / "skills" / "lib.sh"
|
|
cmd_str = f"source {lib_path} && {func_name} " + " ".join(shlex.quote(str(a)) for a in args)
|
|
run_env = dict(os.environ)
|
|
run_env.pop("HERDR_SESSION_NAME", None)
|
|
run_env.pop("HERDR_SERVER_NAME", None)
|
|
if env:
|
|
run_env.update(env)
|
|
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, env=run_env)
|
|
return res
|
|
|
|
def get_mqtt_common(mam_sandbox):
|
|
script_path = str(mam_sandbox / "skills" / "multi-agent-mux-delegate-job" / "scripts")
|
|
if script_path not in sys.path:
|
|
sys.path.insert(0, script_path)
|
|
import mqtt_common
|
|
return mqtt_common
|
|
|
|
|
|
# ==============================================================================
|
|
# FEATURE 1: Create Session (7 Test Cases)
|
|
# ==============================================================================
|
|
|
|
def test_create_derive_session_name_standard(mam_sandbox):
|
|
"""Test standard derive_session_name slug generation."""
|
|
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/user/project", "claude")
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == "user-project-creator-claude"
|
|
|
|
def test_create_derive_session_name_nested(mam_sandbox):
|
|
"""Test derive_session_name with nested paths, upper casing, and underscores."""
|
|
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/User_Name/My_New_Project", "agy")
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == "user-name-my-new-project-creator-agy"
|
|
|
|
def test_create_derive_session_name_weird_characters(mam_sandbox):
|
|
"""Test derive_session_name with spaces and punctuation in the path."""
|
|
res = run_lib_func(mam_sandbox, "derive_session_name", "/a/b c/d-e!f", "hermes")
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == "bc-d-ef-creator-hermes"
|
|
|
|
def test_create_isolation_lever(mam_sandbox):
|
|
"""Test isolation_lever outputs for each supported agent."""
|
|
agents = {
|
|
"claude": "none",
|
|
"cline": "none",
|
|
"agy": "none",
|
|
"hermes": "none",
|
|
"unknown": ""
|
|
}
|
|
for agent, expected in agents.items():
|
|
res = run_lib_func(mam_sandbox, "isolation_lever", agent)
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == expected
|
|
|
|
def test_create_isolation_env_prefix(mam_sandbox):
|
|
"""Test isolation_env_prefix format outputs."""
|
|
res = run_lib_func(mam_sandbox, "isolation_env_prefix", "claude", "/tmp/iso")
|
|
assert res.returncode == 0
|
|
assert res.stdout == ""
|
|
|
|
res2 = run_lib_func(mam_sandbox, "isolation_env_prefix", "agy", "/tmp/iso")
|
|
assert res2.returncode == 0
|
|
assert res2.stdout == ""
|
|
|
|
res3 = run_lib_func(mam_sandbox, "isolation_env_prefix", "cline", "/tmp/iso")
|
|
assert res3.returncode == 0
|
|
assert res3.stdout == ""
|
|
|
|
def test_create_isolation_cmd_args(mam_sandbox):
|
|
"""Test isolation_cmd_args format outputs."""
|
|
res = run_lib_func(mam_sandbox, "isolation_cmd_args", "cline", "/tmp/iso")
|
|
assert res.returncode == 0
|
|
assert res.stdout == ""
|
|
|
|
res2 = run_lib_func(mam_sandbox, "isolation_cmd_args", "claude", "/tmp/iso")
|
|
assert res2.returncode == 0
|
|
assert res2.stdout == ""
|
|
|
|
def test_create_validate_env_key(mam_sandbox):
|
|
"""Test _validate_env_key function with valid and blocked environment keys."""
|
|
# Valid key
|
|
res = run_lib_func(mam_sandbox, "_validate_env_key", "MY_VALID_KEY")
|
|
assert res.returncode == 0
|
|
|
|
# Malformed key (starts with number)
|
|
res2 = run_lib_func(mam_sandbox, "_validate_env_key", "123BAD")
|
|
assert res2.returncode != 0
|
|
|
|
# Blocked key (LD_PRELOAD)
|
|
res3 = run_lib_func(mam_sandbox, "_validate_env_key", "LD_PRELOAD")
|
|
assert res3.returncode != 0
|
|
|
|
|
|
# ==============================================================================
|
|
# FEATURE 2: Resume Session (6 Test Cases)
|
|
# ==============================================================================
|
|
|
|
def test_resume_resolve_herdr_session_default(mam_sandbox):
|
|
"""Test resolve_herdr_workspace fallback behavior when session is not in YAML."""
|
|
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session")
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() != ""
|
|
|
|
def test_resume_resolve_herdr_session_env(mam_sandbox):
|
|
"""Test resolve_herdr_workspace fallback to HERDR_SESSION_NAME or HERDR_SERVER_NAME env var."""
|
|
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SESSION_NAME": "custom_session"})
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == "custom_session"
|
|
|
|
res_legacy = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
|
|
assert res_legacy.returncode == 0
|
|
assert res_legacy.stdout.strip() == "custom_server"
|
|
|
|
def test_resume_find_workspace_uuid_empty(mam_sandbox):
|
|
"""Test find_workspace_uuid returns empty string for non-existent workspace."""
|
|
res = run_lib_func(mam_sandbox, "find_workspace_uuid", "/non/existent/path", "claude")
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == ""
|
|
|
|
def test_resume_find_workspace_uuid_target_non_existent(mam_sandbox):
|
|
"""Test target session query with target that does not exist in YAML."""
|
|
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "claude", "non-existent-session")
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == ""
|
|
|
|
def test_resume_find_workspace_uuid_invalid_agent(mam_sandbox):
|
|
"""Test find_workspace_uuid behavior with an unsupported agent name."""
|
|
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "invalidagent")
|
|
assert res.returncode == 0
|
|
assert res.stdout.strip() == ""
|
|
|
|
def test_resume_script_invalid_args(mam_sandbox):
|
|
"""Test calling resolve_session_id.sh with missing arguments."""
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
|
|
res = subprocess.run(["bash", str(script_path), "--workspace", str(mam_sandbox)], capture_output=True, text=True)
|
|
assert res.returncode == 2
|
|
assert "ERROR: --agent required" in res.stderr
|
|
|
|
|
|
# ==============================================================================
|
|
# FEATURE 3: Stop Session (5 Test Cases)
|
|
# ==============================================================================
|
|
|
|
def test_stop_check_is_nfs_local(mam_sandbox):
|
|
"""Test that _check_is_nfs on local temp directory returns non-zero (not NFS)."""
|
|
res = run_lib_func(mam_sandbox, "_check_is_nfs", str(mam_sandbox))
|
|
# It will exit with 1 if it is not NFS
|
|
assert res.returncode == 1
|
|
|
|
def test_stop_is_already_stopped_not_found(mam_sandbox):
|
|
"""Test is_already_stopped exits with 1 when session is not in YAML."""
|
|
res = run_lib_func(mam_sandbox, "is_already_stopped", "non-existent-session")
|
|
assert res.returncode == 1
|
|
|
|
def test_stop_session_invalid_agent_suffix(mam_sandbox):
|
|
"""Test stop_session.sh fails when agent cannot be inferred from session name."""
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
|
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
|
|
assert res.returncode == 2
|
|
assert "ERROR: cannot infer agent" in res.stderr
|
|
|
|
def test_stop_session_missing_required_args(mam_sandbox):
|
|
"""Test stop_session.sh fails when session name is missing."""
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
|
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
|
|
assert res.returncode == 2
|
|
assert "ERROR: --session required" in res.stderr
|
|
|
|
def test_stop_session_purge_no_yes(mam_sandbox):
|
|
"""Test stop_session.sh exits with 3 when purge is requested without --yes."""
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
|
|
# Seed yaml with the session first to avoid "not in yaml" exit 1
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
yaml_path.write_text("""herdr_sessions:
|
|
- name: test-project-creator-claude
|
|
status: running
|
|
pane:
|
|
cwd: /tmp
|
|
""")
|
|
res = subprocess.run(["bash", str(script_path), "--session", "test-project-creator-claude", "--purge-conversation"], capture_output=True, text=True)
|
|
assert res.returncode == 3
|
|
assert "DANGER: --purge-conversation will DELETE" in res.stdout
|
|
|
|
|
|
# ==============================================================================
|
|
# FEATURE 4: Status Query (5 Test Cases)
|
|
# ==============================================================================
|
|
|
|
def test_status_json_schema_fields(mam_sandbox, mock_herdr, mock_agents):
|
|
"""Verify status.sh output JSON contains the expected structure."""
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
|
|
|
# Run status.sh with --json
|
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
|
assert res.returncode == 0
|
|
|
|
data = json.loads(res.stdout)
|
|
assert "timestamp" in data
|
|
assert "yaml_path" in data
|
|
assert "drifts" in data
|
|
assert "sessions_detail" in data
|
|
|
|
def test_status_text_headers_presence(mam_sandbox):
|
|
"""Verify status.sh output in text mode includes header columns."""
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
|
|
|
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
|
|
assert res.returncode == 0
|
|
assert "NAME" in res.stdout
|
|
assert "WORKSPACE" in res.stdout
|
|
assert "YAML" in res.stdout
|
|
assert "HERDR" in res.stdout
|
|
assert "DRIFT" in res.stdout
|
|
|
|
def test_status_resume_on_disk_helper_claude(mam_sandbox):
|
|
"""Verify resume_on_disk behavior for claude inside status.sh logic via mock YAML queries."""
|
|
# We can write a custom yaml and check status
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
yaml_path.write_text("""herdr_sessions:
|
|
- name: test-project-creator-claude
|
|
status: running
|
|
claude_session_id_own: some-uuid
|
|
pane:
|
|
cwd: /tmp/nonexistent-workspace
|
|
""")
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
|
assert res.returncode == 0
|
|
data = json.loads(res.stdout)
|
|
detail = data["sessions_detail"][0]
|
|
assert detail["name"] == "test-project-creator-claude"
|
|
assert detail["resume_state"] == "MISSING"
|
|
|
|
def test_status_resume_on_disk_helper_agy(mam_sandbox):
|
|
"""Verify resume_on_disk behavior for agy inside status.sh logic."""
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
yaml_path.write_text("""herdr_sessions:
|
|
- name: test-project-creator-agy
|
|
status: running
|
|
agy_conversation_id_own: some-uuid
|
|
pane:
|
|
cwd: /tmp/nonexistent-workspace
|
|
""")
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
|
assert res.returncode == 0
|
|
data = json.loads(res.stdout)
|
|
detail = data["sessions_detail"][0]
|
|
assert detail["name"] == "test-project-creator-agy"
|
|
assert detail["resume_state"] == "MISSING"
|
|
|
|
def test_status_get_job_status_helper(mam_sandbox):
|
|
"""Verify get_job_status parses non-existent jobs gracefully."""
|
|
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
|
|
yaml_path.write_text("""herdr_sessions:
|
|
- name: test-project-creator-claude
|
|
status: running
|
|
delegate_job_id: nonexistent-job-id
|
|
pane:
|
|
cwd: /tmp
|
|
""")
|
|
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
|
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
|
|
assert res.returncode == 0
|
|
data = json.loads(res.stdout)
|
|
detail = data["sessions_detail"][0]
|
|
assert detail["job_id"] == "nonexistent-job-id"
|
|
assert detail["job_status"] == "unknown"
|
|
|
|
|
|
# ==============================================================================
|
|
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
|
|
# ==============================================================================
|
|
|
|
def test_mqtt_topic_prefix_for(mam_sandbox):
|
|
"""Test topic prefix helper methods in mqtt_common."""
|
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
|
prefix = mqtt_common.topic_prefix_for("job123")
|
|
assert prefix == "python/mqtt/jobs/job123"
|
|
|
|
events_topic = mqtt_common.events_topic_for("job123")
|
|
assert events_topic == "python/mqtt/jobs/job123/events"
|
|
|
|
def test_mqtt_verify_hmac_no_token(mam_sandbox):
|
|
"""Verify verify_hmac returns True when no token is present."""
|
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
|
payload = {"data": {"hmac_sig": "somesig"}}
|
|
assert mqtt_common.verify_hmac(payload, None) is True
|
|
assert mqtt_common.verify_hmac(payload, "") is True
|
|
|
|
def test_mqtt_verify_hmac_valid_invalid(mam_sandbox):
|
|
"""Verify verify_hmac signature validation matching logic."""
|
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
|
token = "secret_key"
|
|
payload = {
|
|
"job_id": "job1",
|
|
"event": "started",
|
|
"seq": 1,
|
|
"timestamp": "2026-07-19T00:00:00Z",
|
|
"data": {
|
|
"some_key": "some_val"
|
|
}
|
|
}
|
|
# Calculate HMAC
|
|
msg = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
|
sig = hmac.new(token.encode(), msg, hashlib.sha256).hexdigest()
|
|
|
|
# Put HMAC signature inside data block
|
|
payload["data"]["hmac_sig"] = sig
|
|
|
|
assert mqtt_common.verify_hmac(payload, token) is True
|
|
|
|
# Modifying payload should cause verification to fail
|
|
payload["seq"] = 2
|
|
assert mqtt_common.verify_hmac(payload, token) is False
|
|
|
|
def test_mqtt_reason_code_value(mam_sandbox):
|
|
"""Verify reason_code_value correctly extracts values from paho reason codes."""
|
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
|
|
|
# Test plain int
|
|
assert mqtt_common.reason_code_value(0) == 0
|
|
assert mqtt_common.reason_code_value(5) == 5
|
|
|
|
# Test object with .value attribute
|
|
class DummyReasonCode:
|
|
def __init__(self, val):
|
|
self.value = val
|
|
assert mqtt_common.reason_code_value(DummyReasonCode(0)) == 0
|
|
assert mqtt_common.reason_code_value(DummyReasonCode(16)) == 16
|
|
|
|
def test_mqtt_with_retry_success(mam_sandbox):
|
|
"""Verify with_retry decorator works on direct success."""
|
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
|
|
|
calls = []
|
|
@mqtt_common.with_retry(attempts=3)
|
|
def dummy_func(x):
|
|
calls.append(x)
|
|
return x * 2
|
|
|
|
res = dummy_func(5)
|
|
assert res == 10
|
|
assert calls == [5]
|
|
|
|
def test_mqtt_with_retry_failure(mam_sandbox):
|
|
"""Verify with_retry decorator raises error after specified attempts."""
|
|
mqtt_common = get_mqtt_common(mam_sandbox)
|
|
|
|
calls = []
|
|
@mqtt_common.with_retry(attempts=3, base_delay=0.01)
|
|
def failing_func():
|
|
calls.append(1)
|
|
raise ValueError("failing")
|
|
|
|
with pytest.raises(ValueError, match="failing"):
|
|
failing_func()
|
|
assert len(calls) == 3
|