mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
The test added in bb91cfe used ``CliRunner(mix_stderr=False)`` to keep
stderr separate from stdout for assertion purposes. That parameter was
removed in Click 8.2. The repo's pyproject.toml pins ``click>=8.1.0``,
so either Click 8.1 (needs mix_stderr) or Click 8.2+ (must omit it)
could appear in CI.
Switch to reading ``result.stderr`` when the attribute is populated,
falling back to ``result.output`` (combined stream) otherwise. This
covers every Click 8.x variant without branching on the installed
version.
Verified in the Docker e2e image (Click 8.3.3): all 45 tests in
tests/test_cli/test_init_cli.py pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
925 lines
34 KiB
Python
925 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import click
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
|
|
def _load_init_module(monkeypatch):
|
|
monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False)
|
|
monkeypatch.delitem(sys.modules, "headroom.cli.main", raising=False)
|
|
fake_main_module = types.ModuleType("headroom.cli.main")
|
|
|
|
@click.group()
|
|
def fake_main() -> None:
|
|
pass
|
|
|
|
fake_main_module.main = fake_main
|
|
monkeypatch.setitem(sys.modules, "headroom.cli.main", fake_main_module)
|
|
importlib.invalidate_caches()
|
|
init_cli = importlib.import_module("headroom.cli.init")
|
|
monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False)
|
|
return init_cli, fake_main
|
|
|
|
|
|
def test_init_auto_detects_targets(monkeypatch) -> None:
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
runner = CliRunner()
|
|
captured: dict[str, object] = {}
|
|
|
|
monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: ["claude", "codex"])
|
|
monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs))
|
|
|
|
result = runner.invoke(fake_main, ["init", "-g"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["targets"] == ["claude", "codex"]
|
|
assert captured["global_scope"] is True
|
|
|
|
|
|
def test_init_fails_when_auto_detection_empty(monkeypatch) -> None:
|
|
"""Bare ``headroom init`` with no agents on PATH prints a guided error.
|
|
|
|
Regression guard for issue #245: the error must list every target that
|
|
was probed, confirm that -g / --global is a valid flag, and show the
|
|
explicit per-target invocation so the user knows how to proceed.
|
|
"""
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
runner = CliRunner()
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
result = runner.invoke(fake_main, ["init", "-g"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "No supported user-scope agents were found on PATH" in result.output
|
|
assert "probed the following agents" in result.output
|
|
# Every in-scope target is listed with its lookup status.
|
|
for target in ("claude", "codex", "copilot", "openclaw"):
|
|
assert target in result.output
|
|
# The user is told that -g is still valid and given a concrete next step.
|
|
assert "-g" in result.output
|
|
assert "headroom init -g claude" in result.output
|
|
|
|
|
|
def test_format_empty_detection_error_local_scope(monkeypatch) -> None:
|
|
"""Local-scope variant of the guided error only lists local-scope agents."""
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
message = init_cli._format_empty_detection_error(global_scope=False)
|
|
|
|
assert "local-scope agents" in message
|
|
assert "claude" in message and "codex" in message
|
|
# Copilot / openclaw are global-only; must not be suggested for local.
|
|
assert "headroom init copilot" not in message
|
|
assert "headroom init openclaw" not in message
|
|
assert "headroom init claude" in message
|
|
assert "headroom init codex" in message
|
|
|
|
|
|
def test_format_empty_detection_error_reports_found_paths(monkeypatch, tmp_path) -> None:
|
|
"""When a binary IS present, the error still surfaces its path for debugging."""
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
fake_claude = tmp_path / "claude"
|
|
fake_claude.write_text("")
|
|
monkeypatch.setattr(
|
|
init_cli.shutil,
|
|
"which",
|
|
lambda name: str(fake_claude) if name == "claude" else None,
|
|
)
|
|
|
|
message = init_cli._format_empty_detection_error(global_scope=True)
|
|
|
|
assert f"claude: found at {fake_claude}" in message
|
|
assert "codex: not found" in message
|
|
|
|
|
|
def test_init_verbose_enables_debug_logging_on_stderr(monkeypatch) -> None:
|
|
"""``headroom init -v`` should emit diagnostic lines to stderr.
|
|
|
|
Different Click 8.x versions expose stderr on ``CliRunner`` results
|
|
differently (``mix_stderr`` was removed in 8.2, and ``result.stderr``
|
|
appeared around the same time). To stay compatible with any Click 8.x
|
|
the repo targets, the test reads ``result.stderr`` when the attribute
|
|
exists AND contains data, otherwise falls back to ``result.output``
|
|
(which is the combined stream when stderr isn't captured separately).
|
|
"""
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
runner = CliRunner()
|
|
|
|
result = runner.invoke(fake_main, ["init", "-v", "-g"])
|
|
|
|
# Newer Click: stderr captured separately.
|
|
stderr = getattr(result, "stderr", None) or ""
|
|
if not stderr:
|
|
# Older Click: everything in result.output.
|
|
stderr = result.output
|
|
|
|
assert result.exit_code != 0, f"output: {result.output!r}"
|
|
assert "[headroom init]" in stderr
|
|
assert "detect_init_targets" in stderr
|
|
assert "global_scope=True" in stderr
|
|
for target in ("claude", "codex", "copilot", "openclaw"):
|
|
assert target in stderr
|
|
|
|
|
|
def test_init_verbose_is_idempotent(monkeypatch) -> None:
|
|
"""Calling _enable_verbose_logging repeatedly keeps one handler attached."""
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
# Clear any prior handler state on the dedicated init logger.
|
|
init_cli.logger.handlers.clear()
|
|
if hasattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR):
|
|
delattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR)
|
|
|
|
init_cli._enable_verbose_logging()
|
|
init_cli._enable_verbose_logging()
|
|
init_cli._enable_verbose_logging()
|
|
|
|
assert len(init_cli.logger.handlers) == 1
|
|
|
|
|
|
def test_init_copilot_requires_global(monkeypatch) -> None:
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
runner = CliRunner()
|
|
monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-test")
|
|
|
|
result = runner.invoke(fake_main, ["init", "copilot"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "requires -g" in result.output
|
|
|
|
|
|
def test_init_claude_local_writes_settings_and_installs_marketplace(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
runner = CliRunner()
|
|
monkeypatch.chdir(tmp_path)
|
|
marketplace_calls: list[str] = []
|
|
monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-demo")
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"_install_claude_marketplace",
|
|
lambda scope: marketplace_calls.append(scope),
|
|
)
|
|
|
|
result = runner.invoke(fake_main, ["init", "claude"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
settings_path = tmp_path / ".claude" / "settings.local.json"
|
|
payload = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
assert marketplace_calls == ["local"]
|
|
assert any(
|
|
"--profile init-local-demo" in hook["command"] and "init hook ensure" in hook["command"]
|
|
for entry in payload["hooks"]["SessionStart"]
|
|
for hook in entry["hooks"]
|
|
)
|
|
|
|
|
|
def test_init_codex_merges_feature_flag_into_existing_table(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.chdir(tmp_path)
|
|
config_path = tmp_path / ".codex" / "config.toml"
|
|
config_path.parent.mkdir(parents=True)
|
|
config_path.write_text("[features]\nshell_tool = true\n", encoding="utf-8")
|
|
|
|
init_cli._init_codex(global_scope=False, profile="init-local-demo", port=9000)
|
|
|
|
content = config_path.read_text(encoding="utf-8")
|
|
assert 'base_url = "http://127.0.0.1:9000/v1"' in content
|
|
assert content.count("[features]") == 1
|
|
assert "codex_hooks = true" in content
|
|
hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8"))
|
|
assert "--profile init-local-demo" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
|
assert "init hook ensure" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
|
|
|
|
|
def test_init_claude_uses_custom_port(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr(init_cli, "_install_claude_marketplace", lambda scope: None)
|
|
|
|
init_cli._init_claude(global_scope=False, profile="init-local-demo", port=9011)
|
|
|
|
payload = json.loads((tmp_path / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011"
|
|
|
|
|
|
def test_init_copilot_global_writes_hooks_and_env(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
captured_env: dict[str, str] = {}
|
|
monkeypatch.setattr(init_cli, "_copilot_config_path", lambda: tmp_path / "copilot-config.json")
|
|
monkeypatch.setattr(init_cli, "_apply_user_env", lambda values: captured_env.update(values))
|
|
monkeypatch.setattr(init_cli, "_install_copilot_marketplace", lambda: None)
|
|
|
|
init_cli._init_copilot(global_scope=True, profile="init-user", port=9005, backend="openai")
|
|
|
|
payload = json.loads((tmp_path / "copilot-config.json").read_text(encoding="utf-8"))
|
|
assert "SessionStart" in payload["hooks"]
|
|
assert "PreToolUse" in payload["hooks"]
|
|
assert "--profile init-user" in payload["hooks"]["SessionStart"][0]["command"]
|
|
assert captured_env == {
|
|
"COPILOT_PROVIDER_TYPE": "openai",
|
|
"COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9005/v1",
|
|
"COPILOT_PROVIDER_WIRE_API": "completions",
|
|
}
|
|
|
|
|
|
def test_init_hook_ensure_prefers_local_profile(monkeypatch) -> None:
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
ensured: list[str] = []
|
|
|
|
def fake_load(profile: str):
|
|
return object() if profile == "init-repo-12345678" else None
|
|
|
|
monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678")
|
|
monkeypatch.setattr(init_cli, "load_manifest", fake_load)
|
|
monkeypatch.setattr(
|
|
init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
|
|
)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(fake_main, ["init", "hook", "ensure"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert ensured == ["init-repo-12345678"]
|
|
|
|
|
|
def test_init_openclaw_requires_global(monkeypatch) -> None:
|
|
_, fake_main = _load_init_module(monkeypatch)
|
|
runner = CliRunner()
|
|
|
|
result = runner.invoke(fake_main, ["init", "openclaw"])
|
|
|
|
assert result.exit_code != 0
|
|
assert "requires -g" in result.output
|
|
|
|
|
|
def test_init_openclaw_delegates_to_wrap(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
calls: list[list[str]] = []
|
|
|
|
class _Result:
|
|
returncode = 0
|
|
|
|
monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"])
|
|
monkeypatch.setattr(
|
|
init_cli.subprocess,
|
|
"run",
|
|
lambda cmd: calls.append(cmd) or _Result(),
|
|
)
|
|
|
|
init_cli._init_openclaw(global_scope=True, port=9999)
|
|
|
|
assert calls == [["headroom", "wrap", "openclaw", "--proxy-port", "9999"]]
|
|
|
|
|
|
def test_detect_init_targets_respects_scope(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.setattr(
|
|
init_cli.shutil,
|
|
"which",
|
|
lambda name: name if name in {"claude", "copilot", "codex", "openclaw"} else None,
|
|
)
|
|
|
|
assert init_cli.detect_init_targets(False) == ["claude", "codex"]
|
|
assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"]
|
|
|
|
|
|
def test_marketplace_source_prefers_env_override(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.setenv("HEADROOM_MARKETPLACE_SOURCE", "custom/source")
|
|
|
|
assert init_cli._marketplace_source() == "custom/source"
|
|
|
|
|
|
def test_run_checked_treats_existing_install_as_success(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
class _Result:
|
|
returncode = 1
|
|
stderr = "plugin already exists"
|
|
stdout = ""
|
|
|
|
monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result())
|
|
|
|
init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install")
|
|
|
|
|
|
def test_command_string_and_matcher_on_windows(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
monkeypatch.setattr(init_cli.subprocess, "list2cmdline", lambda parts: "joined-command")
|
|
|
|
assert init_cli._command_string(["headroom", "init"]) == "joined-command"
|
|
assert init_cli._powershell_matcher() == "Bash|PowerShell"
|
|
|
|
|
|
def test_json_file_handles_missing_empty_and_non_mapping(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
missing = tmp_path / "missing.json"
|
|
empty = tmp_path / "empty.json"
|
|
array_payload = tmp_path / "payload.json"
|
|
empty.write_text(" \n", encoding="utf-8")
|
|
array_payload.write_text('["value"]\n', encoding="utf-8")
|
|
|
|
assert init_cli._json_file(missing) == {}
|
|
assert init_cli._json_file(empty) == {}
|
|
assert init_cli._json_file(array_payload) == {}
|
|
|
|
|
|
def test_ensure_claude_hooks_rewrites_existing_entries(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
settings_path = tmp_path / "settings.json"
|
|
settings_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"env": {"KEEP": "1"},
|
|
"hooks": {
|
|
"SessionStart": [
|
|
"not-a-dict",
|
|
{"hooks": "not-a-list"},
|
|
{
|
|
"matcher": "startup|resume",
|
|
"hooks": [{"type": "command", "command": "echo keep-me"}],
|
|
},
|
|
{
|
|
"matcher": "startup|resume",
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "headroom init hook ensure --marker headroom-init-claude",
|
|
}
|
|
],
|
|
},
|
|
]
|
|
},
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure")
|
|
|
|
init_cli._ensure_claude_hooks(settings_path, "init-local-demo", 9001)
|
|
|
|
payload = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
assert payload["env"] == {"KEEP": "1", "ANTHROPIC_BASE_URL": "http://127.0.0.1:9001"}
|
|
session_entries = payload["hooks"]["SessionStart"]
|
|
assert session_entries[0] == "not-a-dict"
|
|
assert session_entries[1] == {"hooks": "not-a-list"}
|
|
assert session_entries[2]["hooks"][0]["command"] == "echo keep-me"
|
|
assert session_entries[-1]["hooks"][0]["command"].endswith("--marker headroom-init-claude")
|
|
|
|
|
|
def test_ensure_copilot_hooks_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
config_path = tmp_path / "copilot.json"
|
|
config_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{"type": "command", "command": "echo keep"},
|
|
{
|
|
"type": "command",
|
|
"command": "headroom init hook ensure --marker headroom-init-copilot",
|
|
},
|
|
]
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure")
|
|
|
|
init_cli._ensure_copilot_hooks(config_path, "init-user")
|
|
|
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
commands = [entry["command"] for entry in payload["hooks"]["SessionStart"]]
|
|
assert commands == ["echo keep", "headroom init hook ensure --marker headroom-init-copilot"]
|
|
|
|
|
|
def test_replace_marker_block_replaces_existing_block(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
content = "before\n# start\nold\n# end\nafter\n"
|
|
|
|
replaced = init_cli._replace_marker_block(content, "# start", "# end", "# start\nnew\n# end")
|
|
|
|
assert replaced == "before\n\nafter\n\n# start\nnew\n# end\n"
|
|
|
|
|
|
def test_ensure_codex_provider_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
path = tmp_path / "config.toml"
|
|
path.write_text(
|
|
f"prefix\n{init_cli._CODEX_PROVIDER_MARKER_START}\nold = true\n{init_cli._CODEX_PROVIDER_MARKER_END}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
init_cli._ensure_codex_provider(path, 9100)
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
assert content.count(init_cli._CODEX_PROVIDER_MARKER_START) == 1
|
|
assert 'base_url = "http://127.0.0.1:9100/v1"' in content
|
|
assert "old = true" not in content
|
|
|
|
|
|
def test_ensure_codex_feature_flag_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
path = tmp_path / "config.toml"
|
|
path.write_text(
|
|
f"[features]\n{init_cli._CODEX_FEATURE_MARKER_START}\ncodex_hooks = false\n{init_cli._CODEX_FEATURE_MARKER_END}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1
|
|
assert "codex_hooks = true" in content
|
|
|
|
|
|
def test_ensure_codex_feature_flag_skips_duplicate_existing_setting(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
path = tmp_path / "config.toml"
|
|
path.write_text("[features]\ncodex_hooks = true\nshell_tool = true\n", encoding="utf-8")
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
assert content.count("codex_hooks = true") == 1
|
|
assert init_cli._CODEX_FEATURE_MARKER_START not in content
|
|
|
|
|
|
def test_ensure_codex_feature_flag_creates_features_section_when_missing(
|
|
monkeypatch, tmp_path: Path
|
|
) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
path = tmp_path / "config.toml"
|
|
path.write_text('model = "gpt-5"\n', encoding="utf-8")
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
assert "[features]" in content
|
|
assert "codex_hooks = true" in content
|
|
|
|
|
|
def test_manifest_changed_detects_differences(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
existing = SimpleNamespace(
|
|
port=8787,
|
|
backend="anthropic",
|
|
anyllm_provider=None,
|
|
region=None,
|
|
memory_enabled=False,
|
|
)
|
|
|
|
assert not init_cli._manifest_changed(
|
|
existing,
|
|
port=8787,
|
|
backend="anthropic",
|
|
anyllm_provider=None,
|
|
region=None,
|
|
memory=False,
|
|
)
|
|
assert init_cli._manifest_changed(
|
|
existing,
|
|
port=9000,
|
|
backend="anthropic",
|
|
anyllm_provider=None,
|
|
region=None,
|
|
memory=False,
|
|
)
|
|
|
|
|
|
def test_ensure_runtime_manifest_merges_targets_and_stops_changed_runtime(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
existing = SimpleNamespace(
|
|
targets=["claude"],
|
|
mutations=["mutation"],
|
|
port=8787,
|
|
backend="anthropic",
|
|
anyllm_provider=None,
|
|
region=None,
|
|
memory_enabled=False,
|
|
)
|
|
saved: list[object] = []
|
|
stopped: list[object] = []
|
|
built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[])
|
|
|
|
monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user")
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"build_manifest",
|
|
lambda **kwargs: built.__dict__.update(kwargs) or built,
|
|
)
|
|
monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest))
|
|
monkeypatch.setattr(init_cli, "stop_runtime", lambda manifest: stopped.append(manifest))
|
|
|
|
profile = init_cli._ensure_runtime_manifest(
|
|
global_scope=True,
|
|
targets=["codex"],
|
|
port=9001,
|
|
backend="anthropic",
|
|
anyllm_provider=None,
|
|
region=None,
|
|
memory=False,
|
|
)
|
|
|
|
assert profile == "init-user"
|
|
assert stopped == [existing]
|
|
assert saved == [built]
|
|
assert built.targets == ["claude", "codex"]
|
|
assert built.mutations == ["mutation"]
|
|
assert built.supervisor_kind == init_cli.SupervisorKind.NONE.value
|
|
assert built.artifacts == []
|
|
|
|
|
|
def test_ensure_runtime_manifest_ignores_stop_runtime_errors(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
existing = SimpleNamespace(
|
|
targets=[],
|
|
mutations=[],
|
|
port=8787,
|
|
backend="anthropic",
|
|
anyllm_provider=None,
|
|
region=None,
|
|
memory_enabled=False,
|
|
)
|
|
saved: list[object] = []
|
|
built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[])
|
|
|
|
monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user")
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"build_manifest",
|
|
lambda **kwargs: built.__dict__.update(kwargs) or built,
|
|
)
|
|
monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest))
|
|
monkeypatch.setattr(
|
|
init_cli, "stop_runtime", lambda manifest: (_ for _ in ()).throw(RuntimeError("boom"))
|
|
)
|
|
|
|
init_cli._ensure_runtime_manifest(
|
|
global_scope=True,
|
|
targets=["claude"],
|
|
port=9001,
|
|
backend="anthropic",
|
|
anyllm_provider=None,
|
|
region=None,
|
|
memory=False,
|
|
)
|
|
|
|
assert saved == [built]
|
|
|
|
|
|
def test_apply_user_env_routes_by_platform(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
manifest = SimpleNamespace(base_env={"OLD": "1"}, tool_envs={})
|
|
windows_calls: list[object] = []
|
|
unix_calls: list[object] = []
|
|
monkeypatch.setattr(init_cli, "_env_manifest", lambda values: manifest)
|
|
monkeypatch.setattr(
|
|
init_cli, "_apply_windows_env_scope", lambda value: windows_calls.append(value)
|
|
)
|
|
monkeypatch.setattr(init_cli, "_apply_unix_env_scope", lambda value: unix_calls.append(value))
|
|
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "openai"})
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="posix"))
|
|
init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "anthropic"})
|
|
|
|
assert manifest.base_env == {}
|
|
assert manifest.tool_envs == {"copilot": {"COPILOT_PROVIDER_TYPE": "anthropic"}}
|
|
assert windows_calls == [manifest]
|
|
assert unix_calls == [manifest]
|
|
|
|
|
|
def test_resolve_copilot_env_supports_anthropic(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
assert init_cli._resolve_copilot_env(9010, "anthropic") == {
|
|
"COPILOT_PROVIDER_TYPE": "anthropic",
|
|
"COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9010",
|
|
}
|
|
|
|
|
|
def test_marketplace_source_prefers_repo_checkout(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.delenv("HEADROOM_MARKETPLACE_SOURCE", raising=False)
|
|
|
|
assert init_cli._marketplace_source() == str(Path(init_cli.__file__).resolve().parents[2])
|
|
|
|
|
|
def test_run_checked_raises_on_failure(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
class _Result:
|
|
returncode = 2
|
|
stderr = "bad stderr"
|
|
stdout = "bad stdout"
|
|
|
|
monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result())
|
|
|
|
with pytest.raises(
|
|
click.ClickException, match="claude plugin install failed: bad stderr\nbad stdout"
|
|
):
|
|
init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install")
|
|
|
|
|
|
def test_install_claude_marketplace_errors_without_binary(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
with pytest.raises(click.ClickException, match="'claude' not found"):
|
|
init_cli._install_claude_marketplace("local")
|
|
|
|
|
|
def test_install_claude_marketplace_runs_expected_commands(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
calls: list[tuple[list[str], str]] = []
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: "claude")
|
|
monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source")
|
|
monkeypatch.setattr(
|
|
init_cli, "_run_checked", lambda command, action: calls.append((command, action))
|
|
)
|
|
|
|
init_cli._install_claude_marketplace("user")
|
|
|
|
assert calls == [
|
|
(["claude", "plugin", "marketplace", "add", "repo/source"], "claude marketplace add"),
|
|
(
|
|
["claude", "plugin", "install", "headroom@headroom-marketplace", "--scope", "user"],
|
|
"claude plugin install",
|
|
),
|
|
]
|
|
|
|
|
|
def test_install_copilot_marketplace_handles_missing_binary(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
with pytest.raises(click.ClickException, match="'copilot' not found"):
|
|
init_cli._install_copilot_marketplace()
|
|
|
|
|
|
def test_install_copilot_marketplace_runs_expected_commands(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
calls: list[tuple[list[str], str]] = []
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: "copilot")
|
|
monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source")
|
|
monkeypatch.setattr(
|
|
init_cli, "_run_checked", lambda command, action: calls.append((command, action))
|
|
)
|
|
|
|
init_cli._install_copilot_marketplace()
|
|
|
|
assert calls == [
|
|
(["copilot", "plugin", "marketplace", "add", "repo/source"], "copilot marketplace add"),
|
|
(
|
|
["copilot", "plugin", "install", "headroom@headroom-marketplace"],
|
|
"copilot plugin install",
|
|
),
|
|
]
|
|
|
|
|
|
def test_ensure_profile_running_covers_runtime_modes(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
docker_manifest = SimpleNamespace(
|
|
preset=init_cli.InstallPreset.PERSISTENT_DOCKER.value,
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
profile="docker-profile",
|
|
)
|
|
service_manifest = SimpleNamespace(
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
supervisor_kind=init_cli.SupervisorKind.SERVICE.value,
|
|
profile="service-profile",
|
|
)
|
|
task_manifest = SimpleNamespace(
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
profile="task-profile",
|
|
)
|
|
manifests = {
|
|
"docker-profile": docker_manifest,
|
|
"service-profile": service_manifest,
|
|
"task-profile": task_manifest,
|
|
}
|
|
docker_calls: list[object] = []
|
|
service_calls: list[object] = []
|
|
detached_calls: list[str] = []
|
|
wait_calls: list[tuple[str, int]] = []
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifests.get(profile))
|
|
|
|
def fake_wait_ready(manifest, timeout_seconds: int) -> bool:
|
|
wait_calls.append((manifest.profile, timeout_seconds))
|
|
return False
|
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", fake_wait_ready)
|
|
monkeypatch.setattr(
|
|
init_cli, "start_persistent_docker", lambda manifest: docker_calls.append(manifest)
|
|
)
|
|
monkeypatch.setattr(
|
|
init_cli, "start_supervisor", lambda manifest: service_calls.append(manifest)
|
|
)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"start_detached_agent",
|
|
lambda profile: detached_calls.append(profile),
|
|
)
|
|
|
|
init_cli._ensure_profile_running("missing")
|
|
init_cli._ensure_profile_running("docker-profile")
|
|
init_cli._ensure_profile_running("service-profile")
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
assert docker_calls == [docker_manifest]
|
|
assert service_calls == [service_manifest]
|
|
assert detached_calls == ["task-profile"]
|
|
assert ("docker-profile", 1) in wait_calls
|
|
assert ("docker-profile", 45) in wait_calls
|
|
|
|
|
|
def test_ensure_profile_running_returns_when_ready_or_on_exception(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
manifest = SimpleNamespace(
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
profile="task-profile",
|
|
)
|
|
detached_calls: list[str] = []
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest)
|
|
monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: True)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"start_detached_agent",
|
|
lambda profile: detached_calls.append(profile),
|
|
)
|
|
|
|
init_cli._ensure_profile_running("task-profile")
|
|
assert detached_calls == []
|
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: False)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"start_detached_agent",
|
|
lambda profile: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
)
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
|
|
def test_init_codex_windows_warns_about_upstream_hook_limitation(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
messages: list[str] = []
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
monkeypatch.setattr(init_cli, "_codex_scope_path", lambda global_scope: Path("config.toml"))
|
|
monkeypatch.setattr(init_cli, "_codex_hooks_path", lambda global_scope: Path("hooks.json"))
|
|
monkeypatch.setattr(init_cli, "_ensure_codex_provider", lambda path, port: None)
|
|
monkeypatch.setattr(init_cli, "_ensure_codex_feature_flag", lambda path: None)
|
|
monkeypatch.setattr(init_cli, "_ensure_codex_hooks", lambda path, profile: None)
|
|
monkeypatch.setattr(init_cli.click, "echo", lambda message: messages.append(message))
|
|
|
|
init_cli._init_codex(global_scope=True, profile="init-user", port=9000)
|
|
|
|
assert any("disabled upstream on Windows" in message for message in messages)
|
|
|
|
|
|
def test_init_openclaw_propagates_nonzero_exit(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
class _Result:
|
|
returncode = 9
|
|
|
|
monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"])
|
|
monkeypatch.setattr(init_cli.subprocess, "run", lambda command: _Result())
|
|
|
|
with pytest.raises(SystemExit) as exc:
|
|
init_cli._init_openclaw(global_scope=True, port=9999)
|
|
|
|
assert exc.value.code == 9
|
|
|
|
|
|
def test_run_init_targets_dispatches_supported_targets(monkeypatch) -> None:
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
calls: list[tuple[str, tuple[object, ...]]] = []
|
|
monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-profile")
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"_init_claude",
|
|
lambda **kwargs: calls.append(
|
|
("claude", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"_init_copilot",
|
|
lambda **kwargs: calls.append(
|
|
("copilot", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"_init_codex",
|
|
lambda **kwargs: calls.append(
|
|
("codex", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"_init_openclaw",
|
|
lambda **kwargs: calls.append(("openclaw", (kwargs["global_scope"], kwargs["port"]))),
|
|
)
|
|
|
|
init_cli._run_init_targets(
|
|
targets=["claude", "copilot", "codex", "openclaw"],
|
|
global_scope=True,
|
|
port=9000,
|
|
backend="openai",
|
|
anyllm_provider="provider",
|
|
region="us-east-1",
|
|
memory=True,
|
|
)
|
|
|
|
assert calls == [
|
|
("claude", (True, "init-profile", 9000)),
|
|
("copilot", (True, "init-profile", 9000)),
|
|
("codex", (True, "init-profile", 9000)),
|
|
("openclaw", (True, 9000)),
|
|
]
|
|
|
|
|
|
def test_init_subcommand_uses_group_options(monkeypatch) -> None:
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
runner = CliRunner()
|
|
captured: dict[str, object] = {}
|
|
monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs))
|
|
|
|
result = runner.invoke(
|
|
fake_main,
|
|
["init", "-g", "--port", "9007", "--backend", "openai", "--memory", "claude"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured == {
|
|
"targets": ["claude"],
|
|
"global_scope": True,
|
|
"port": 9007,
|
|
"backend": "openai",
|
|
"anyllm_provider": None,
|
|
"region": None,
|
|
"memory": True,
|
|
}
|
|
|
|
|
|
def test_init_hook_ensure_prefers_global_when_local_missing(monkeypatch) -> None:
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
ensured: list[str] = []
|
|
monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678")
|
|
monkeypatch.setattr(
|
|
init_cli,
|
|
"load_manifest",
|
|
lambda profile: object() if profile == init_cli._GLOBAL_PROFILE else None,
|
|
)
|
|
monkeypatch.setattr(
|
|
init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
|
|
)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(fake_main, ["init", "hook", "ensure"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert ensured == [init_cli._GLOBAL_PROFILE]
|
|
|
|
|
|
def test_init_hook_ensure_uses_explicit_profile(monkeypatch) -> None:
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
ensured: list[str] = []
|
|
monkeypatch.setattr(
|
|
init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
|
|
)
|
|
|
|
runner = CliRunner()
|
|
result = runner.invoke(fake_main, ["init", "hook", "ensure", "--profile", "init-explicit"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert ensured == ["init-explicit"]
|