mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
test: cover init install flows end to end
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
c5d795c2af
commit
a278a7b0ba
7 changed files with 463 additions and 69 deletions
136
.dockerignore
136
.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/**
|
||||
|
|
|
|||
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
.github/workflows/init-e2e.yml
vendored
Normal file
22
.github/workflows/init-e2e.yml
vendored
Normal file
|
|
@ -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
|
||||
33
e2e/init/Dockerfile
Normal file
33
e2e/init/Dockerfile
Normal file
|
|
@ -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"]
|
||||
236
e2e/init/run.py
Normal file
236
e2e/init/run.py
Normal file
|
|
@ -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()
|
||||
68
scripts/tests/test_sync_plugin_versions.py
Normal file
68
scripts/tests/test_sync_plugin_versions.py
Normal file
|
|
@ -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",
|
||||
]
|
||||
]
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue