diff --git a/.dockerignore b/.dockerignore index 0fa069013..5e7b346a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,63 +1,73 @@ -# VCS -.git -.github -.gitignore - -# Python artifacts -__pycache__ -*.pyc -*.pyo -*.egg-info -dist/ -build/ - -# Dev/test tooling -.pytest_cache -.coverage -.mypy_cache -.ruff_cache -.pre-commit-config.yaml -.venv -venv - -# Tests & docs (not needed in image) -tests/ -docs/ -mkdocs.yml -CHANGELOG.md -LICENSE -NOTICE - -# JS/TS artifacts (dashboard, SDK — not part of proxy image) -apps/ -sdk/ -!sdk/ -!sdk/typescript/ -!sdk/typescript/** -plugins/ -!plugins/ -!plugins/openclaw/ -!plugins/openclaw/** -node_modules/ -*.tgz - -# Secrets & local config -.env -.env.* -*.log - -# IDE -.vscode -.idea -*.swp - -# Docker -Dockerfile -docker-compose*.yml -.dockerignore - -# Misc -.pi-lens/ -.superpowers/ -examples/ -node-compile-cache/ +# VCS +.git +.github +.github/* +!.github/plugin/ +!.github/plugin/** +.gitignore + +# Python artifacts +__pycache__ +*.pyc +*.pyo +*.egg-info +dist/ +build/ + +# Dev/test tooling +.pytest_cache +.coverage +.mypy_cache +.ruff_cache +.pre-commit-config.yaml +.venv +venv + +# Tests & docs (not needed in image) +tests/ +docs/ +mkdocs.yml +CHANGELOG.md +LICENSE +NOTICE + +# JS/TS artifacts (dashboard, SDK — not part of proxy image) +apps/ +sdk/ +!sdk/ +!sdk/typescript/ +!sdk/typescript/** +plugins/ +!plugins/ +!plugins/openclaw/ +!plugins/openclaw/** +!plugins/headroom-agent-hooks/ +!plugins/headroom-agent-hooks/** +node_modules/ +*.tgz + +# Secrets & local config +.env +.env.* +*.log + +# IDE +.vscode +.idea +*.swp + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# Misc +.pi-lens/ +.superpowers/ +examples/ +node-compile-cache/ +!e2e/ +!e2e/init/ +!e2e/init/** +!.claude-plugin/ +!.claude-plugin/** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83700960b..d5101ab8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,12 +49,12 @@ jobs: - name: Run tests run: | - pytest -v --tb=short + pytest -v --tb=short tests scripts/tests - name: Run tests with coverage if: matrix.python-version == '3.11' run: | - pytest --cov=headroom --cov-report=xml --cov-report=term-missing + pytest tests scripts/tests --cov=headroom --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov if: matrix.python-version == '3.11' @@ -146,6 +146,11 @@ jobs: docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e . docker run --rm headroom-wrap-e2e + - name: Run Docker-native init e2e + run: | + docker build -f e2e/init/Dockerfile -t headroom-init-e2e . + docker run --rm headroom-init-e2e + windows-native-wrapper: runs-on: windows-latest steps: @@ -215,10 +220,10 @@ jobs: name: dist path: dist/ - commitlint: - if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'Merge pull request ') - runs-on: ubuntu-latest - steps: + commitlint: + if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'Merge pull request ') + runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/init-e2e.yml b/.github/workflows/init-e2e.yml new file mode 100644 index 000000000..607cbb33e --- /dev/null +++ b/.github/workflows/init-e2e.yml @@ -0,0 +1,22 @@ +name: Init E2E + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +jobs: + docker-init-e2e: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v4 + + - name: Build init e2e image + run: docker build -f e2e/init/Dockerfile -t headroom-init-e2e . + + - name: Run init e2e container + run: docker run --rm headroom-init-e2e diff --git a/e2e/init/Dockerfile b/e2e/init/Dockerfile new file mode 100644 index 000000000..5836d3ef4 --- /dev/null +++ b/e2e/init/Dockerfile @@ -0,0 +1,33 @@ +FROM node:22-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + PATH="/opt/headroom-venv/bin:${PATH}" \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + python3 \ + python3-pip \ + python3-venv && \ + ln -sf /usr/bin/python3 /usr/local/bin/python && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /workspace + +COPY pyproject.toml README.md uv.lock ./ +COPY headroom ./headroom +COPY .claude-plugin ./.claude-plugin +COPY .github/plugin ./.github/plugin +COPY plugins/headroom-agent-hooks ./plugins/headroom-agent-hooks +COPY e2e/init ./e2e/init + +RUN python -m venv /opt/headroom-venv && \ + /opt/headroom-venv/bin/python -m pip install --upgrade pip && \ + /opt/headroom-venv/bin/python -m pip install -e ".[proxy]" + +CMD ["python", "e2e/init/run.py"] diff --git a/e2e/init/run.py b/e2e/init/run.py new file mode 100644 index 000000000..4a1b14b34 --- /dev/null +++ b/e2e/init/run.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import json +import os +import stat +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +from headroom.cli import init as init_cli + +REPO_ROOT = Path("/workspace") +HEADROOM = "headroom" + + +def log(message: str) -> None: + print(f"[init-e2e] {message}", flush=True) + + +def run( + cmd: list[str], + *, + env: dict[str, str], + cwd: Path, + timeout: int = 180, +) -> subprocess.CompletedProcess[str]: + log(f"$ {' '.join(cmd)}") + result = subprocess.run( + cmd, + env=env, + cwd=str(cwd), + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + if result.stdout.strip(): + print(result.stdout.rstrip(), flush=True) + if result.stderr.strip(): + print(result.stderr.rstrip(), file=sys.stderr, flush=True) + if result.returncode != 0: + raise RuntimeError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}") + return result + + +def assert_true(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + +def read_jsonl(path: Path) -> list[dict[str, object]]: + if not path.exists(): + return [] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line] + + +def create_agent_shims(shim_dir: Path, log_path: Path) -> None: + shim = textwrap.dedent( + """\ + #!/usr/bin/env python3 + from __future__ import annotations + + import json + import os + import sys + from pathlib import Path + + record = { + "tool": Path(sys.argv[0]).name, + "argv": sys.argv[1:], + "cwd": os.getcwd(), + } + log_path = Path(os.environ["HEADROOM_INIT_E2E_LOG"]) + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record) + "\\n") + print(f"{record['tool']} shim executed") + raise SystemExit(0) + """ + ) + shim_dir.mkdir(parents=True, exist_ok=True) + for name in ("claude", "copilot"): + write_executable(shim_dir / name, shim) + + +def expect_hook_command(command: str, profile: str) -> None: + assert_true("init hook ensure" in command, f"missing init hook ensure in: {command}") + assert_true(f"--profile {profile}" in command, f"missing profile {profile} in: {command}") + + +def read_manifest(home_dir: Path, profile: str) -> dict[str, object]: + path = home_dir / ".headroom" / "deploy" / profile / "manifest.json" + assert_true(path.exists(), f"Expected manifest at {path}") + return json.loads(path.read_text(encoding="utf-8")) + + +def verify_claude_local(home_dir: Path, project_dir: Path, shim_log: Path) -> None: + settings = json.loads( + (project_dir / ".claude" / "settings.local.json").read_text(encoding="utf-8") + ) + assert_true( + settings["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011", + "Claude local settings should point at the requested proxy port", + ) + session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"] + profile = init_cli._local_profile(project_dir) + expect_hook_command(session_start, profile) + expect_hook_command(pre_tool, profile) + + manifest = read_manifest(home_dir, profile) + assert_true("claude" in manifest["targets"], "Claude init should register the claude target") + + claude_calls = [record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "claude"] + assert_true( + claude_calls + == [ + ["plugin", "marketplace", "add", str(REPO_ROOT)], + ["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"], + ], + f"Unexpected Claude install commands: {claude_calls}", + ) + + +def verify_copilot_global(home_dir: Path, shim_log: Path) -> None: + config = json.loads((home_dir / ".copilot" / "config.json").read_text(encoding="utf-8")) + assert_true( + "SessionStart" in config["hooks"], "Copilot config should include SessionStart hooks" + ) + assert_true("PreToolUse" in config["hooks"], "Copilot config should include PreToolUse hooks") + session_start = config["hooks"]["SessionStart"][0]["command"] + expect_hook_command(session_start, "init-user") + + for shell_file in (home_dir / ".bashrc", home_dir / ".zshrc", home_dir / ".profile"): + content = shell_file.read_text(encoding="utf-8") + assert_true( + 'export COPILOT_PROVIDER_TYPE="openai"' in content, + f"{shell_file.name} should contain the Copilot provider type", + ) + assert_true( + 'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"' in content, + f"{shell_file.name} should contain the Copilot provider base URL", + ) + assert_true( + 'export COPILOT_PROVIDER_WIRE_API="completions"' in content, + f"{shell_file.name} should contain the Copilot wire API", + ) + + copilot_calls = [ + record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "copilot" + ] + assert_true( + copilot_calls + == [ + ["plugin", "marketplace", "add", str(REPO_ROOT)], + ["plugin", "install", "headroom@headroom-marketplace"], + ], + f"Unexpected Copilot install commands: {copilot_calls}", + ) + + +def verify_codex_local(home_dir: Path, project_dir: Path) -> None: + config_path = project_dir / ".codex" / "config.toml" + hooks_path = project_dir / ".codex" / "hooks.json" + config = config_path.read_text(encoding="utf-8") + hooks = json.loads(hooks_path.read_text(encoding="utf-8")) + profile = init_cli._local_profile(project_dir) + + assert_true( + 'base_url = "http://127.0.0.1:9012/v1"' in config, + "Codex config should point at the requested proxy port", + ) + assert_true( + config.count("[features]") == 1, "Codex config should keep a single [features] table" + ) + assert_true("codex_hooks = true" in config, "Codex config should enable codex_hooks") + command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"] + expect_hook_command(command, profile) + + manifest = read_manifest(home_dir, profile) + targets = manifest["targets"] + assert_true(set(targets) == {"claude", "codex"}, f"Unexpected merged targets: {targets}") + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="headroom-init-e2e-") as temp_root_raw: + temp_root = Path(temp_root_raw) + home_dir = temp_root / "home" + project_dir = temp_root / "project" + shim_dir = temp_root / "bin" + shim_log = temp_root / "shim-log.jsonl" + home_dir.mkdir(parents=True) + project_dir.mkdir(parents=True) + create_agent_shims(shim_dir, shim_log) + + env = os.environ.copy() + env["HOME"] = str(home_dir) + env["USERPROFILE"] = str(home_dir) + env["HEADROOM_INIT_E2E_LOG"] = str(shim_log) + env["PATH"] = f"{shim_dir}:{env['PATH']}" + + run([HEADROOM, "init", "--port", "9011", "claude"], env=env, cwd=project_dir) + verify_claude_local(home_dir, project_dir, shim_log) + + run( + [ + HEADROOM, + "init", + "-g", + "--port", + "9005", + "--backend", + "openai", + "copilot", + ], + env=env, + cwd=project_dir, + ) + verify_copilot_global(home_dir, shim_log) + + run([HEADROOM, "init", "--port", "9012", "codex"], env=env, cwd=project_dir) + verify_codex_local(home_dir, project_dir) + + log("Init e2e completed successfully") + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/test_sync_plugin_versions.py b/scripts/tests/test_sync_plugin_versions.py new file mode 100644 index 000000000..66c2361e5 --- /dev/null +++ b/scripts/tests/test_sync_plugin_versions.py @@ -0,0 +1,68 @@ +"""Tests for sync-plugin-versions.py.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +def _load_module(): + script = Path(__file__).parent.parent / "sync-plugin-versions.py" + spec = importlib.util.spec_from_file_location("sync_plugin_versions", script) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_compute_repo_semver_uses_release_helpers(monkeypatch) -> None: + module = _load_module() + calls: dict[str, object] = {} + + monkeypatch.setattr(module, "list_release_tags", lambda root: ["v0.9.0"]) + monkeypatch.setattr(module, "find_latest_release_tag", lambda tags: "v0.9.0") + monkeypatch.setattr(module, "list_release_commits", lambda root, tag: ["feat: add init"]) + monkeypatch.setattr(module, "determine_bump_level", lambda commits: "minor") + monkeypatch.setattr(module, "get_canonical_version", lambda root: "0.5.25") + + def fake_compute_release_version(*, canonical_version: str, level: str, tags: list[str]): + calls["canonical_version"] = canonical_version + calls["level"] = level + calls["tags"] = tags + return type("Info", (), {"npm_version": "0.10.0"})() + + monkeypatch.setattr(module, "compute_release_version", fake_compute_release_version) + + assert module.compute_repo_semver(Path("repo")) == "0.10.0" + assert calls == { + "canonical_version": "0.5.25", + "level": "minor", + "tags": ["v0.9.0"], + } + + +def test_main_runs_plugin_only_version_sync(monkeypatch) -> None: + module = _load_module() + commands: list[list[str]] = [] + + monkeypatch.setattr(module, "compute_repo_semver", lambda root: "0.10.0") + monkeypatch.setattr( + module.subprocess, + "run", + lambda command, cwd, check: commands.append(command), + ) + + module.main() + + assert commands == [ + [ + module.sys.executable, + str(module.ROOT / "scripts" / "version-sync.py"), + "--root", + str(module.ROOT), + "--version", + "0.10.0", + "--plugin-manifests-only", + ] + ] diff --git a/tests/test_cli/test_init_cli.py b/tests/test_cli/test_init_cli.py index 44af1c5dc..3bf60bc4f 100644 --- a/tests/test_cli/test_init_cli.py +++ b/tests/test_cli/test_init_cli.py @@ -200,3 +200,23 @@ def test_detect_init_targets_respects_scope(monkeypatch) -> 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")