After upstream switched to SEA (Single Executable Application) layout in v2.1.114+, there is no cli.js anymore — only bin/claude.exe. Before this fix: - find_cli_js() searched only for cli.js → returned None on SEA installs - get_installed_version() returned (None, None) → "Claude Code: not installed" - is_patched() returned (False, [3 markers]) → false-negative even after successful patch - cmd_check showed "not installed" right after a successful update - cmd_update would loop reinstalling because patch markers seemed missing This contributed to a user-facing incident where /model picker showed only built-in models even though CLAUDE_CUSTOM_MODELS was set in settings.json: the binary update path was triggering uninstall+reinstall cycles instead of being recognised as already-patched. Changes: - New find_claude_artifact() finds either cli.js or bin/claude.exe, including the deeply-nested layout npm uses for SEA wrapper packages - find_all_cli_js() returns both legacy and SEA artifacts - is_patched() auto-detects layout: text scan for cli.js, bytes scan for claude.exe (markers: /*ae1_models_filter_patched*/, /*bypass_permissions_prompt*/) - get_installed_version() reads package.json next to bin/ for SEA - uclaude_install.sh adds binary marker verification at end of install so users immediately see if the SEA payload was patched Tests: new tests/test_sea_detect.py covers all 4 detector functions against fake SEA install layouts (including nested form). All 20 tests pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
168 lines
6.0 KiB
Python
168 lines
6.0 KiB
Python
"""Regression tests for SEA-aware detection in uclaude_updater.py.
|
|
|
|
Background: before this fix, find_cli_js() searched ONLY for `cli.js`
|
|
files. After upstream switched to SEA layout (v2.1.114+), `cli.js` no
|
|
longer exists — there's only `bin/claude.exe`. As a result:
|
|
- find_cli_js() returned None
|
|
- get_installed_version() returned (None, None)
|
|
- is_patched() returned (False, [3 markers]) — false negative
|
|
- cmd_check showed "Claude Code: not installed" right after install
|
|
|
|
These tests pin down the SEA-aware behaviour so it cannot regress.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch as mock_patch
|
|
|
|
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 _make_sea_install(npm_root: Path, version: str = "2.1.119",
|
|
patched: bool = True, nested: bool = False) -> Path:
|
|
"""Create a fake SEA install layout under npm_root.
|
|
|
|
nested=True → also nest a copy under
|
|
npm_root/@anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code/
|
|
matching how npm SEA wrapper packages get unpacked.
|
|
"""
|
|
pkg = npm_root / "@anthropic-ai" / "claude-code"
|
|
if nested:
|
|
pkg = pkg / "node_modules" / "@anthropic-ai" / "claude-code"
|
|
bin_dir = pkg / "bin"
|
|
bin_dir.mkdir(parents=True)
|
|
payload = b"\x7fELF" + (b"X" * 4096)
|
|
if patched:
|
|
payload += b"/*ae1_models_filter_patched*/" + (b"Y" * 1024)
|
|
payload += b"/*bypass_permissions_prompt*/" + (b"Z" * 512)
|
|
binary = bin_dir / "claude.exe"
|
|
binary.write_bytes(payload)
|
|
os.chmod(binary, 0o755)
|
|
(pkg / "cli-wrapper.cjs").write_text("// wrapper\n")
|
|
(pkg / "package.json").write_text(json.dumps({"version": version}))
|
|
return binary
|
|
|
|
|
|
def test_find_claude_artifact_finds_sea_binary(tmp_path):
|
|
"""SEA install: bin/claude.exe must be discovered when no cli.js."""
|
|
npm_root = tmp_path / "npm_global"
|
|
npm_root.mkdir()
|
|
binary = _make_sea_install(npm_root)
|
|
with mock_patch("subprocess.run") as mrun:
|
|
mrun.return_value = type("R", (), {
|
|
"returncode": 0, "stdout": str(npm_root), "stderr": "",
|
|
})()
|
|
with mock_patch("os.path.isfile", side_effect=lambda p: Path(p).is_file()):
|
|
with mock_patch.object(uu, "IS_WINDOWS", False):
|
|
got = uu.find_claude_artifact()
|
|
assert got is not None
|
|
assert Path(got).name == "claude.exe"
|
|
|
|
|
|
def test_find_claude_artifact_finds_nested_sea(tmp_path):
|
|
"""Nested layout: @anthropic-ai/claude-code/node_modules/@anthropic-ai/claude-code/bin/claude.exe"""
|
|
npm_root = tmp_path / "npm_global"
|
|
npm_root.mkdir()
|
|
binary = _make_sea_install(npm_root, nested=True)
|
|
with mock_patch("subprocess.run") as mrun:
|
|
# which claude → realpath returns the nested binary
|
|
def _run(cmd, **kw):
|
|
class R: pass
|
|
r = R()
|
|
r.returncode = 0
|
|
r.stderr = ""
|
|
if cmd[:1] == ["which"]:
|
|
r.stdout = str(binary)
|
|
else:
|
|
r.stdout = str(npm_root)
|
|
return r
|
|
mrun.side_effect = _run
|
|
with mock_patch.object(uu, "IS_WINDOWS", False):
|
|
got = uu.find_claude_artifact()
|
|
assert got is not None
|
|
assert Path(got).resolve() == binary.resolve()
|
|
|
|
|
|
def test_is_patched_sea_recognises_marker_in_binary(tmp_path):
|
|
"""SEA: is_patched scans binary as bytes, not text."""
|
|
npm_root = tmp_path / "npm"
|
|
npm_root.mkdir()
|
|
binary = _make_sea_install(npm_root, patched=True)
|
|
patched, missing = uu.is_patched(str(binary))
|
|
assert patched is True
|
|
assert missing == []
|
|
|
|
|
|
def test_is_patched_sea_unpatched_returns_false(tmp_path):
|
|
"""SEA without ae1/bypass markers must report not patched."""
|
|
npm_root = tmp_path / "npm"
|
|
npm_root.mkdir()
|
|
binary = _make_sea_install(npm_root, patched=False)
|
|
patched, missing = uu.is_patched(str(binary))
|
|
assert patched is False
|
|
# Both SEA markers should be reported missing
|
|
assert any("ae1" in m for m in missing)
|
|
assert any("bypass" in m for m in missing)
|
|
|
|
|
|
def test_is_patched_legacy_cli_js_still_works(tmp_path):
|
|
"""Legacy cli.js path: text-based scan unchanged."""
|
|
cli_js = tmp_path / "cli.js"
|
|
cli_js.write_text(
|
|
"// __CLAUDE_SETTINGS__\n"
|
|
"function x(){return!!(/*bypass_permissions_prompt*/false)}\n"
|
|
"/* root check removed by patcher */\n"
|
|
)
|
|
patched, missing = uu.is_patched(str(cli_js))
|
|
assert patched is True
|
|
assert missing == []
|
|
|
|
|
|
def test_is_patched_returns_false_on_missing_file():
|
|
patched, missing = uu.is_patched("/nonexistent/path")
|
|
assert patched is False
|
|
assert len(missing) > 0
|
|
|
|
|
|
def test_get_installed_version_sea_reads_package_json(tmp_path):
|
|
"""SEA: version comes from package.json next to bin/."""
|
|
npm_root = tmp_path / "npm"
|
|
npm_root.mkdir()
|
|
binary = _make_sea_install(npm_root, version="2.1.119")
|
|
with mock_patch.object(uu, "find_claude_artifact", return_value=str(binary)):
|
|
v, p = uu.get_installed_version()
|
|
assert v == "2.1.119"
|
|
assert Path(p).name == "claude.exe"
|
|
|
|
|
|
def test_get_installed_version_returns_none_when_missing():
|
|
with mock_patch.object(uu, "find_claude_artifact", return_value=None):
|
|
v, p = uu.get_installed_version()
|
|
assert v is None
|
|
assert p is None
|
|
|
|
|
|
def test_find_all_cli_js_returns_sea_artifact(tmp_path):
|
|
"""Multi-install detector must also surface SEA binaries."""
|
|
npm_root = tmp_path / "npm"
|
|
npm_root.mkdir()
|
|
binary = _make_sea_install(npm_root)
|
|
with mock_patch("subprocess.run") as mrun:
|
|
mrun.return_value = type("R", (), {
|
|
"returncode": 0, "stdout": str(npm_root), "stderr": "",
|
|
})()
|
|
with mock_patch.object(uu, "IS_WINDOWS", False):
|
|
paths = uu.find_all_cli_js()
|
|
sea_paths = [p for p in paths if p.endswith("claude.exe")]
|
|
assert len(sea_paths) >= 1
|