"""TDD — uclaude_updater.py SEA install support. Verifies that install_sea_release(): - Locates releases/v/sea/{claude, cli-wrapper.cjs, manifest.json} - Installs via npm: ensures @anthropic-ai/claude-code@ + native pkg - Replaces installed bin/claude.exe with patched binary (sha256 match) - Replaces installed cli-wrapper.cjs with patched wrapper - Updates /usr/bin/claude symlink to bin/claude.exe (npm convention) - Verifies sha256 of installed binary matches manifest Why TDD: this is a high-risk function — it touches /usr/lib system paths. Tests use a temp install_root to avoid touching the real system. """ from __future__ import annotations import hashlib import importlib.util import json import os import shutil import sys from pathlib import Path from unittest.mock import patch import pytest # Load uclaude_updater as module (no package init at claude/) ROOT = Path(__file__).resolve().parent.parent spec = importlib.util.spec_from_file_location( "uclaude_updater", ROOT / "uclaude_updater.py" ) uu = importlib.util.module_from_spec(spec) sys.modules["uclaude_updater"] = uu spec.loader.exec_module(uu) def _sha256(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 16), b""): h.update(chunk) return h.hexdigest() def _make_release_dir(root: Path, version: str, binary_bytes: bytes, wrapper_text: str) -> Path: """Build releases/v/sea/{claude, cli-wrapper.cjs, manifest.json}.""" sea = root / "releases" / f"v{version}" / "sea" sea.mkdir(parents=True) (sea / "claude").write_bytes(binary_bytes) os.chmod(sea / "claude", 0o755) (sea / "cli-wrapper.cjs").write_text(wrapper_text) manifest = { "version": version, "binary_sha256": _sha256(sea / "claude"), "wrapper_sha256": _sha256(sea / "cli-wrapper.cjs"), "binary_size": (sea / "claude").stat().st_size, "wrapper_size": (sea / "cli-wrapper.cjs").stat().st_size, "install_type": "sea_binary", } (sea / "manifest.json").write_text(json.dumps(manifest, indent=2)) return sea def _make_existing_install(install_root: Path, version: str = "2.1.100") -> None: """Build /usr/lib/node_modules/@anthropic-ai/claude-code/* style structure.""" pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" bin_dir = pkg / "bin" bin_dir.mkdir(parents=True) (bin_dir / "claude.exe").write_bytes(b"\x7fELForigvOLD") os.chmod(bin_dir / "claude.exe", 0o755) (pkg / "cli-wrapper.cjs").write_text("// old wrapper\n") (pkg / "package.json").write_text(json.dumps({"version": version})) # ---------- detect_release_type ---------- def test_detect_release_type_finds_sea(tmp_path): _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew", "// new\n") assert uu.detect_release_type(str(tmp_path), "2.1.119") == "sea" def test_detect_release_type_finds_legacy_cli_js(tmp_path): rel = tmp_path / "releases" / "v2.1.100" rel.mkdir(parents=True) (rel / "cli.js").write_text("// legacy") assert uu.detect_release_type(str(tmp_path), "2.1.100") == "cli_js" def test_detect_release_type_returns_none_when_missing(tmp_path): assert uu.detect_release_type(str(tmp_path), "9.9.9") is None # ---------- install_sea_release ---------- def test_install_sea_creates_files_in_install_root(tmp_path): sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 100, "// wrapper\n") install_root = tmp_path / "system_install" _make_existing_install(install_root, version="2.1.100") ok = uu.install_sea_release( repo_root=str(tmp_path), version="2.1.119", install_root=str(install_root), bin_symlink_target=None, ) assert ok is True pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" assert (pkg / "cli-wrapper.cjs").read_text() == "// wrapper\n" assert (pkg / "bin" / "claude.exe").exists() # sha256 must match manifest manifest = json.loads((sea / "manifest.json").read_text()) assert _sha256(pkg / "bin" / "claude.exe") == manifest["binary_sha256"] def test_install_sea_returns_false_when_release_missing(tmp_path): install_root = tmp_path / "system" install_root.mkdir() ok = uu.install_sea_release( repo_root=str(tmp_path), version="9.9.9", install_root=str(install_root), ) assert ok is False def test_install_sea_creates_backup_of_existing_binary(tmp_path): _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") install_root = tmp_path / "system" _make_existing_install(install_root, version="2.1.100") uu.install_sea_release( repo_root=str(tmp_path), version="2.1.119", install_root=str(install_root), ) pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" backups = list((pkg / "bin").glob("claude.exe.bak.*")) assert len(backups) >= 1, f"no backup created in {pkg / 'bin'}" def test_install_sea_rolls_back_on_sha256_mismatch(tmp_path): """If the installed binary sha doesn't match the manifest after copy (e.g. fs corruption), rollback from backup must restore old binary.""" sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") install_root = tmp_path / "system" _make_existing_install(install_root, version="2.1.100") pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" binary_dst = pkg / "bin" / "claude.exe" old_bytes = binary_dst.read_bytes() # Tamper manifest to force sha mismatch (simulates corrupted install) manifest_path = sea / "manifest.json" manifest = json.loads(manifest_path.read_text()) manifest["binary_sha256"] = "0" * 64 # wrong hash manifest_path.write_text(json.dumps(manifest, indent=2)) ok = uu.install_sea_release( repo_root=str(tmp_path), version="2.1.119", install_root=str(install_root), ) assert ok is False # Old binary must be restored assert binary_dst.read_bytes() == old_bytes def test_install_sea_uses_atomic_copy(tmp_path): """Atomic copy must use .new staging so partial copy never leaves binary_dst in inconsistent state.""" sea = _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") install_root = tmp_path / "system" _make_existing_install(install_root, version="2.1.100") pkg = install_root / "node_modules" / "@anthropic-ai" / "claude-code" uu.install_sea_release( repo_root=str(tmp_path), version="2.1.119", install_root=str(install_root), ) # No leftover .new files (would indicate failed atomic rename) leftover = list(pkg.rglob("*.new")) assert leftover == [], f"leftover .new files: {leftover}" def test_atomic_copy_cleans_up_new_on_exception(tmp_path): """If copy or fsync raises mid-flight, the .new staging file must be cleaned up (no orphan files left in install_root).""" src = tmp_path / "src.bin" src.write_bytes(b"\x7fELFcontent") dst = tmp_path / "dst.bin" # Force chmod failure: pass an invalid mode value to trigger TypeError # AFTER the .new file was created with pytest.raises(TypeError): uu._atomic_copy_with_fsync(str(src), str(dst), mode="not-an-int") # No leftover .new assert not (tmp_path / "dst.bin.new").exists(), "orphan .new file left behind" # Original dst never created assert not dst.exists() def test_install_sea_acquires_lock(tmp_path): """Verify install creates and releases the .uclaude-update.lock file.""" _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") install_root = tmp_path / "system" _make_existing_install(install_root, version="2.1.100") uu.install_sea_release( repo_root=str(tmp_path), version="2.1.119", install_root=str(install_root), ) lock_file = install_root / ".uclaude-update.lock" assert lock_file.exists(), "lock file should be created" def test_install_sea_updates_symlink(tmp_path): _make_release_dir(tmp_path, "2.1.119", b"\x7fELFnew" + b"\x00" * 50, "// w\n") install_root = tmp_path / "system" _make_existing_install(install_root, version="2.1.100") symlink = tmp_path / "bin" / "claude" symlink.parent.mkdir(parents=True) # Old symlink → cli.js (legacy) old_target = install_root / "node_modules" / "@anthropic-ai" / "claude-code" / "cli.js" old_target.write_text("// old cli.js stub\n") os.symlink(str(old_target), str(symlink)) uu.install_sea_release( repo_root=str(tmp_path), version="2.1.119", install_root=str(install_root), bin_symlink_target=str(symlink), ) new_target = install_root / "node_modules" / "@anthropic-ai" / "claude-code" / "bin" / "claude.exe" assert os.readlink(str(symlink)) == str(new_target)