"""Deploy key-level registry merge tests for .agents/hooks.json. Tests for Rev.2 key-level 3-way merge rules (R-1 to R-10). """ import json import os import shutil import subprocess import tempfile import pytest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) @pytest.fixture def ws(): """A pristine source copy plus empty target directory.""" tmp = tempfile.mkdtemp(prefix="mam_deploy_reg_") src, tgt = os.path.join(tmp, "src"), os.path.join(tmp, "tgt") shutil.copytree(REPO_ROOT, src, ignore=shutil.ignore_patterns(".git", ".venv", ".mam", ".mam_deploy", "__pycache__"), symlinks=True) os.makedirs(tgt) try: yield src, tgt finally: shutil.rmtree(tmp, ignore_errors=True) def _install(src, tgt, **extra): env = dict(os.environ) env.update({"MAM_REPO_URL": src, "MAM_SKIP_VENV": "1", "MAM_SKIP_GITIGNORE": "1"}) env.update(extra) return subprocess.run(["bash", os.path.join(src, "deploy", "install.sh"), tgt], env=env, capture_output=True, text=True) def _install_mam(src, tgt): return subprocess.run(["bash", os.path.join(src, "deploy", "install_mam.sh"), "--target", tgt], capture_output=True, text=True) def _read_json(path): with open(path, encoding="utf-8") as f: return json.load(f) def _write_json(path, data): with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2) def _add_user_hook(tgt, name="custom-user-hook"): p = os.path.join(tgt, ".agents", "hooks.json") d = _read_json(p) d[name] = {"PreToolUse": [{"matcher": "user_action", "hooks": [{"type": "command", "command": "echo user"}]}]} _write_json(p, d) return name def _ship_new_hook(src, name="mam-loop-timeout-guard"): script = os.path.join(src, ".agents", "hooks", "loop_timeout_guard.sh") with open(script, "w") as f: f.write('#!/usr/bin/env bash\necho \'{"decision":"allow"}\'\n') os.chmod(script, 0o755) p = os.path.join(src, ".agents", "hooks.json") d = _read_json(p) d[name] = {"PreToolUse": [{"matcher": "file_change", "hooks": [ {"type": "command", "command": "./hooks/loop_timeout_guard.sh", "timeout": 10}]}]} _write_json(p, d) return name def test_r1_user_hook_does_not_block_new_framework_hook(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 user_hook = _add_user_hook(tgt) new_hook = _ship_new_hook(src) res = _install(src, tgt) assert res.returncode == 0, res.stderr tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json")) assert user_hook in tgt_data, "User's custom hook was lost" assert new_hook in tgt_data, "New framework hook was not delivered" def test_r2_upstream_fix_to_an_existing_hook_lands(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 _add_user_hook(tgt) p_src = os.path.join(src, ".agents", "hooks.json") d_src = _read_json(p_src) key = list(d_src.keys())[0] d_src[key]["updated_by_upstream"] = True _write_json(p_src, d_src) res = _install(src, tgt) assert res.returncode == 0, res.stderr tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json")) assert tgt_data.get(key, {}).get("updated_by_upstream") is True def test_r3_user_modified_hook_is_preserved_and_reported(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 p_tgt = os.path.join(tgt, ".agents", "hooks.json") d_tgt = _read_json(p_tgt) key = list(d_tgt.keys())[0] d_tgt[key]["user_custom_setting"] = 123 _write_json(p_tgt, d_tgt) p_src = os.path.join(src, ".agents", "hooks.json") d_src = _read_json(p_src) d_src[key]["upstream_competing_setting"] = 456 _write_json(p_src, d_src) res = _install(src, tgt) assert res.returncode == 0, res.stderr tgt_data = _read_json(p_tgt) assert tgt_data[key].get("user_custom_setting") == 123, "User edit was overwritten" assert "kept yours" in res.stderr or "kept your" in res.stderr or "Registry merged" in res.stderr def test_r4_retired_upstream_hook_removed_if_unmodified(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 p_src = os.path.join(src, ".agents", "hooks.json") d_src = _read_json(p_src) retired_key = list(d_src.keys())[0] del d_src[retired_key] _write_json(p_src, d_src) res = _install(src, tgt) assert res.returncode == 0, res.stderr tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json")) assert retired_key not in tgt_data, "Retired unmodified hook was not removed" def test_r4b_retired_upstream_hook_kept_if_user_modified(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 p_tgt = os.path.join(tgt, ".agents", "hooks.json") d_tgt = _read_json(p_tgt) retired_key = list(d_tgt.keys())[0] d_tgt[retired_key]["modified_by_user"] = True _write_json(p_tgt, d_tgt) p_src = os.path.join(src, ".agents", "hooks.json") d_src = _read_json(p_src) del d_src[retired_key] _write_json(p_src, d_src) res = _install(src, tgt) assert res.returncode == 0, res.stderr tgt_data = _read_json(p_tgt) assert retired_key in tgt_data, "User-modified retired hook should be kept" def test_r5_merge_preserves_backup_and_stderr_notification(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 _add_user_hook(tgt) _ship_new_hook(src) res = _install(src, tgt) assert res.returncode == 0, res.stderr assert "Registry merged" in res.stderr or "added" in res.stderr backup_dir = os.path.join(tgt, ".mam", "skill-backups") assert os.path.exists(backup_dir) and len(os.listdir(backup_dir)) > 0 def test_r6_missing_base_falls_back_to_additive_merge(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 shutil.rmtree(os.path.join(tgt, ".mam", "base"), ignore_errors=True) user_hook = _add_user_hook(tgt) new_hook = _ship_new_hook(src) res = _install(src, tgt) assert res.returncode == 0, res.stderr tgt_data = _read_json(os.path.join(tgt, ".agents", "hooks.json")) assert user_hook in tgt_data assert new_hook in tgt_data def test_r7_corrupt_registry_preserved_and_install_continues(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 p_tgt = os.path.join(tgt, ".agents", "hooks.json") with open(p_tgt, "w") as f: f.write("INVALID JSON {{{") res = _install(src, tgt) assert res.returncode == 0, res.stderr with open(p_tgt) as f: assert "INVALID JSON" in f.read() def test_r8_merge_is_idempotent(ws): src, tgt = ws assert _install(src, tgt).returncode == 0 _add_user_hook(tgt) _ship_new_hook(src) res1 = _install(src, tgt) assert res1.returncode == 0 t1_content = open(os.path.join(tgt, ".agents", "hooks.json")).read() res2 = _install(src, tgt) assert res2.returncode == 0 t2_content = open(os.path.join(tgt, ".agents", "hooks.json")).read() assert t1_content == t2_content def test_r9_install_mam_records_hash_db_and_base(ws): src, tgt = ws res = _install_mam(src, tgt) assert res.returncode == 0, res.stderr assert os.path.exists(os.path.join(tgt, ".mam", "asset_hashes.txt")) assert os.path.exists(os.path.join(tgt, ".mam", "base", ".agents", "hooks.json")) def test_r10_missing_ownership_rules_abort_rather_than_degrade(ws): src, tgt = ws lib = os.path.join(src, "deploy", "lib_ownership.sh") assert os.path.exists(lib), "deploy/lib_ownership.sh is not shipped" os.remove(lib) res = _install(src, tgt) assert res.returncode != 0 assert "lib_ownership.sh" in res.stderr