Implement full E2E, integration, component, and unit tests, and resolve all leftover tmux-to-herdr issues in UI and core scripts
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
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)
|
||||
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": "claude_config_dir",
|
||||
"cline": "cline_data_dir",
|
||||
"agy": "home",
|
||||
"hermes": "home",
|
||||
"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 == "CLAUDE_CONFIG_DIR=/tmp/iso "
|
||||
|
||||
res2 = run_lib_func(mam_sandbox, "isolation_env_prefix", "agy", "/tmp/iso")
|
||||
assert res2.returncode == 0
|
||||
assert res2.stdout == "HOME=/tmp/iso "
|
||||
|
||||
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 == "--data-dir /tmp/iso"
|
||||
|
||||
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_workspace_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() == "w1"
|
||||
|
||||
def test_resume_resolve_herdr_workspace_env(mam_sandbox):
|
||||
"""Test resolve_herdr_workspace fallback to HERDR_SERVER_NAME env var."""
|
||||
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
|
||||
assert res.returncode == 0
|
||||
assert res.stdout.strip() == "w1" # default mapping to w1 unless resolved from workspace list
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user