Spaces:
Build error
Build error
Commit ·
a9a8a0e
1
Parent(s): e517426
test: cover init install flows end to end
Browse filesCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- .dockerignore +73 -63
- .github/workflows/ci.yml +11 -6
- .github/workflows/init-e2e.yml +22 -0
- e2e/init/Dockerfile +33 -0
- e2e/init/run.py +236 -0
- scripts/tests/test_sync_plugin_versions.py +68 -0
- tests/test_cli/test_init_cli.py +20 -0
.dockerignore
CHANGED
|
@@ -1,63 +1,73 @@
|
|
| 1 |
-
# VCS
|
| 2 |
-
.git
|
| 3 |
-
.github
|
| 4 |
-
.
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
.
|
| 19 |
-
.
|
| 20 |
-
.
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
!
|
| 39 |
-
!
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
*.
|
| 48 |
-
|
| 49 |
-
#
|
| 50 |
-
.
|
| 51 |
-
.
|
| 52 |
-
*.
|
| 53 |
-
|
| 54 |
-
#
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
.
|
| 58 |
-
|
| 59 |
-
#
|
| 60 |
-
|
| 61 |
-
.
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# VCS
|
| 2 |
+
.git
|
| 3 |
+
.github
|
| 4 |
+
.github/*
|
| 5 |
+
!.github/plugin/
|
| 6 |
+
!.github/plugin/**
|
| 7 |
+
.gitignore
|
| 8 |
+
|
| 9 |
+
# Python artifacts
|
| 10 |
+
__pycache__
|
| 11 |
+
*.pyc
|
| 12 |
+
*.pyo
|
| 13 |
+
*.egg-info
|
| 14 |
+
dist/
|
| 15 |
+
build/
|
| 16 |
+
|
| 17 |
+
# Dev/test tooling
|
| 18 |
+
.pytest_cache
|
| 19 |
+
.coverage
|
| 20 |
+
.mypy_cache
|
| 21 |
+
.ruff_cache
|
| 22 |
+
.pre-commit-config.yaml
|
| 23 |
+
.venv
|
| 24 |
+
venv
|
| 25 |
+
|
| 26 |
+
# Tests & docs (not needed in image)
|
| 27 |
+
tests/
|
| 28 |
+
docs/
|
| 29 |
+
mkdocs.yml
|
| 30 |
+
CHANGELOG.md
|
| 31 |
+
LICENSE
|
| 32 |
+
NOTICE
|
| 33 |
+
|
| 34 |
+
# JS/TS artifacts (dashboard, SDK — not part of proxy image)
|
| 35 |
+
apps/
|
| 36 |
+
sdk/
|
| 37 |
+
!sdk/
|
| 38 |
+
!sdk/typescript/
|
| 39 |
+
!sdk/typescript/**
|
| 40 |
+
plugins/
|
| 41 |
+
!plugins/
|
| 42 |
+
!plugins/openclaw/
|
| 43 |
+
!plugins/openclaw/**
|
| 44 |
+
!plugins/headroom-agent-hooks/
|
| 45 |
+
!plugins/headroom-agent-hooks/**
|
| 46 |
+
node_modules/
|
| 47 |
+
*.tgz
|
| 48 |
+
|
| 49 |
+
# Secrets & local config
|
| 50 |
+
.env
|
| 51 |
+
.env.*
|
| 52 |
+
*.log
|
| 53 |
+
|
| 54 |
+
# IDE
|
| 55 |
+
.vscode
|
| 56 |
+
.idea
|
| 57 |
+
*.swp
|
| 58 |
+
|
| 59 |
+
# Docker
|
| 60 |
+
Dockerfile
|
| 61 |
+
docker-compose*.yml
|
| 62 |
+
.dockerignore
|
| 63 |
+
|
| 64 |
+
# Misc
|
| 65 |
+
.pi-lens/
|
| 66 |
+
.superpowers/
|
| 67 |
+
examples/
|
| 68 |
+
node-compile-cache/
|
| 69 |
+
!e2e/
|
| 70 |
+
!e2e/init/
|
| 71 |
+
!e2e/init/**
|
| 72 |
+
!.claude-plugin/
|
| 73 |
+
!.claude-plugin/**
|
.github/workflows/ci.yml
CHANGED
|
@@ -49,12 +49,12 @@ jobs:
|
|
| 49 |
|
| 50 |
- name: Run tests
|
| 51 |
run: |
|
| 52 |
-
pytest -v --tb=short
|
| 53 |
|
| 54 |
- name: Run tests with coverage
|
| 55 |
if: matrix.python-version == '3.11'
|
| 56 |
run: |
|
| 57 |
-
pytest --cov=headroom --cov-report=xml --cov-report=term-missing
|
| 58 |
|
| 59 |
- name: Upload coverage to Codecov
|
| 60 |
if: matrix.python-version == '3.11'
|
|
@@ -146,6 +146,11 @@ jobs:
|
|
| 146 |
docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
|
| 147 |
docker run --rm headroom-wrap-e2e
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
windows-native-wrapper:
|
| 150 |
runs-on: windows-latest
|
| 151 |
steps:
|
|
@@ -215,10 +220,10 @@ jobs:
|
|
| 215 |
name: dist
|
| 216 |
path: dist/
|
| 217 |
|
| 218 |
-
commitlint:
|
| 219 |
-
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'Merge pull request ')
|
| 220 |
-
runs-on: ubuntu-latest
|
| 221 |
-
steps:
|
| 222 |
- uses: actions/checkout@v4
|
| 223 |
with:
|
| 224 |
fetch-depth: 0
|
|
|
|
| 49 |
|
| 50 |
- name: Run tests
|
| 51 |
run: |
|
| 52 |
+
pytest -v --tb=short tests scripts/tests
|
| 53 |
|
| 54 |
- name: Run tests with coverage
|
| 55 |
if: matrix.python-version == '3.11'
|
| 56 |
run: |
|
| 57 |
+
pytest tests scripts/tests --cov=headroom --cov-report=xml --cov-report=term-missing
|
| 58 |
|
| 59 |
- name: Upload coverage to Codecov
|
| 60 |
if: matrix.python-version == '3.11'
|
|
|
|
| 146 |
docker build -f e2e/wrap/Dockerfile -t headroom-wrap-e2e .
|
| 147 |
docker run --rm headroom-wrap-e2e
|
| 148 |
|
| 149 |
+
- name: Run Docker-native init e2e
|
| 150 |
+
run: |
|
| 151 |
+
docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
|
| 152 |
+
docker run --rm headroom-init-e2e
|
| 153 |
+
|
| 154 |
windows-native-wrapper:
|
| 155 |
runs-on: windows-latest
|
| 156 |
steps:
|
|
|
|
| 220 |
name: dist
|
| 221 |
path: dist/
|
| 222 |
|
| 223 |
+
commitlint:
|
| 224 |
+
if: github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'Merge pull request ')
|
| 225 |
+
runs-on: ubuntu-latest
|
| 226 |
+
steps:
|
| 227 |
- uses: actions/checkout@v4
|
| 228 |
with:
|
| 229 |
fetch-depth: 0
|
.github/workflows/init-e2e.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Init E2E
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
branches: [main]
|
| 6 |
+
push:
|
| 7 |
+
branches: [main]
|
| 8 |
+
workflow_dispatch:
|
| 9 |
+
|
| 10 |
+
jobs:
|
| 11 |
+
docker-init-e2e:
|
| 12 |
+
runs-on: ubuntu-latest
|
| 13 |
+
timeout-minutes: 45
|
| 14 |
+
|
| 15 |
+
steps:
|
| 16 |
+
- uses: actions/checkout@v4
|
| 17 |
+
|
| 18 |
+
- name: Build init e2e image
|
| 19 |
+
run: docker build -f e2e/init/Dockerfile -t headroom-init-e2e .
|
| 20 |
+
|
| 21 |
+
- name: Run init e2e container
|
| 22 |
+
run: docker run --rm headroom-init-e2e
|
e2e/init/Dockerfile
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:22-bookworm
|
| 2 |
+
|
| 3 |
+
ENV DEBIAN_FRONTEND=noninteractive \
|
| 4 |
+
PATH="/opt/headroom-venv/bin:${PATH}" \
|
| 5 |
+
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
| 6 |
+
PIP_NO_CACHE_DIR=1 \
|
| 7 |
+
PYTHONUNBUFFERED=1 \
|
| 8 |
+
PYTHONDONTWRITEBYTECODE=1
|
| 9 |
+
|
| 10 |
+
RUN apt-get update && \
|
| 11 |
+
apt-get install -y --no-install-recommends \
|
| 12 |
+
ca-certificates \
|
| 13 |
+
git \
|
| 14 |
+
python3 \
|
| 15 |
+
python3-pip \
|
| 16 |
+
python3-venv && \
|
| 17 |
+
ln -sf /usr/bin/python3 /usr/local/bin/python && \
|
| 18 |
+
rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
WORKDIR /workspace
|
| 21 |
+
|
| 22 |
+
COPY pyproject.toml README.md uv.lock ./
|
| 23 |
+
COPY headroom ./headroom
|
| 24 |
+
COPY .claude-plugin ./.claude-plugin
|
| 25 |
+
COPY .github/plugin ./.github/plugin
|
| 26 |
+
COPY plugins/headroom-agent-hooks ./plugins/headroom-agent-hooks
|
| 27 |
+
COPY e2e/init ./e2e/init
|
| 28 |
+
|
| 29 |
+
RUN python -m venv /opt/headroom-venv && \
|
| 30 |
+
/opt/headroom-venv/bin/python -m pip install --upgrade pip && \
|
| 31 |
+
/opt/headroom-venv/bin/python -m pip install -e ".[proxy]"
|
| 32 |
+
|
| 33 |
+
CMD ["python", "e2e/init/run.py"]
|
e2e/init/run.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import stat
|
| 6 |
+
import subprocess
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
import textwrap
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from headroom.cli import init as init_cli
|
| 13 |
+
|
| 14 |
+
REPO_ROOT = Path("/workspace")
|
| 15 |
+
HEADROOM = "headroom"
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def log(message: str) -> None:
|
| 19 |
+
print(f"[init-e2e] {message}", flush=True)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def run(
|
| 23 |
+
cmd: list[str],
|
| 24 |
+
*,
|
| 25 |
+
env: dict[str, str],
|
| 26 |
+
cwd: Path,
|
| 27 |
+
timeout: int = 180,
|
| 28 |
+
) -> subprocess.CompletedProcess[str]:
|
| 29 |
+
log(f"$ {' '.join(cmd)}")
|
| 30 |
+
result = subprocess.run(
|
| 31 |
+
cmd,
|
| 32 |
+
env=env,
|
| 33 |
+
cwd=str(cwd),
|
| 34 |
+
capture_output=True,
|
| 35 |
+
text=True,
|
| 36 |
+
encoding="utf-8",
|
| 37 |
+
errors="replace",
|
| 38 |
+
timeout=timeout,
|
| 39 |
+
)
|
| 40 |
+
if result.stdout.strip():
|
| 41 |
+
print(result.stdout.rstrip(), flush=True)
|
| 42 |
+
if result.stderr.strip():
|
| 43 |
+
print(result.stderr.rstrip(), file=sys.stderr, flush=True)
|
| 44 |
+
if result.returncode != 0:
|
| 45 |
+
raise RuntimeError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}")
|
| 46 |
+
return result
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def assert_true(condition: bool, message: str) -> None:
|
| 50 |
+
if not condition:
|
| 51 |
+
raise AssertionError(message)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def write_executable(path: Path, content: str) -> None:
|
| 55 |
+
path.write_text(content, encoding="utf-8")
|
| 56 |
+
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def read_jsonl(path: Path) -> list[dict[str, object]]:
|
| 60 |
+
if not path.exists():
|
| 61 |
+
return []
|
| 62 |
+
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def create_agent_shims(shim_dir: Path, log_path: Path) -> None:
|
| 66 |
+
shim = textwrap.dedent(
|
| 67 |
+
"""\
|
| 68 |
+
#!/usr/bin/env python3
|
| 69 |
+
from __future__ import annotations
|
| 70 |
+
|
| 71 |
+
import json
|
| 72 |
+
import os
|
| 73 |
+
import sys
|
| 74 |
+
from pathlib import Path
|
| 75 |
+
|
| 76 |
+
record = {
|
| 77 |
+
"tool": Path(sys.argv[0]).name,
|
| 78 |
+
"argv": sys.argv[1:],
|
| 79 |
+
"cwd": os.getcwd(),
|
| 80 |
+
}
|
| 81 |
+
log_path = Path(os.environ["HEADROOM_INIT_E2E_LOG"])
|
| 82 |
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
with log_path.open("a", encoding="utf-8") as handle:
|
| 84 |
+
handle.write(json.dumps(record) + "\\n")
|
| 85 |
+
print(f"{record['tool']} shim executed")
|
| 86 |
+
raise SystemExit(0)
|
| 87 |
+
"""
|
| 88 |
+
)
|
| 89 |
+
shim_dir.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
for name in ("claude", "copilot"):
|
| 91 |
+
write_executable(shim_dir / name, shim)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def expect_hook_command(command: str, profile: str) -> None:
|
| 95 |
+
assert_true("init hook ensure" in command, f"missing init hook ensure in: {command}")
|
| 96 |
+
assert_true(f"--profile {profile}" in command, f"missing profile {profile} in: {command}")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def read_manifest(home_dir: Path, profile: str) -> dict[str, object]:
|
| 100 |
+
path = home_dir / ".headroom" / "deploy" / profile / "manifest.json"
|
| 101 |
+
assert_true(path.exists(), f"Expected manifest at {path}")
|
| 102 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def verify_claude_local(home_dir: Path, project_dir: Path, shim_log: Path) -> None:
|
| 106 |
+
settings = json.loads(
|
| 107 |
+
(project_dir / ".claude" / "settings.local.json").read_text(encoding="utf-8")
|
| 108 |
+
)
|
| 109 |
+
assert_true(
|
| 110 |
+
settings["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011",
|
| 111 |
+
"Claude local settings should point at the requested proxy port",
|
| 112 |
+
)
|
| 113 |
+
session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
| 114 |
+
pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
|
| 115 |
+
profile = init_cli._local_profile(project_dir)
|
| 116 |
+
expect_hook_command(session_start, profile)
|
| 117 |
+
expect_hook_command(pre_tool, profile)
|
| 118 |
+
|
| 119 |
+
manifest = read_manifest(home_dir, profile)
|
| 120 |
+
assert_true("claude" in manifest["targets"], "Claude init should register the claude target")
|
| 121 |
+
|
| 122 |
+
claude_calls = [record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "claude"]
|
| 123 |
+
assert_true(
|
| 124 |
+
claude_calls
|
| 125 |
+
== [
|
| 126 |
+
["plugin", "marketplace", "add", str(REPO_ROOT)],
|
| 127 |
+
["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"],
|
| 128 |
+
],
|
| 129 |
+
f"Unexpected Claude install commands: {claude_calls}",
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def verify_copilot_global(home_dir: Path, shim_log: Path) -> None:
|
| 134 |
+
config = json.loads((home_dir / ".copilot" / "config.json").read_text(encoding="utf-8"))
|
| 135 |
+
assert_true(
|
| 136 |
+
"SessionStart" in config["hooks"], "Copilot config should include SessionStart hooks"
|
| 137 |
+
)
|
| 138 |
+
assert_true("PreToolUse" in config["hooks"], "Copilot config should include PreToolUse hooks")
|
| 139 |
+
session_start = config["hooks"]["SessionStart"][0]["command"]
|
| 140 |
+
expect_hook_command(session_start, "init-user")
|
| 141 |
+
|
| 142 |
+
for shell_file in (home_dir / ".bashrc", home_dir / ".zshrc", home_dir / ".profile"):
|
| 143 |
+
content = shell_file.read_text(encoding="utf-8")
|
| 144 |
+
assert_true(
|
| 145 |
+
'export COPILOT_PROVIDER_TYPE="openai"' in content,
|
| 146 |
+
f"{shell_file.name} should contain the Copilot provider type",
|
| 147 |
+
)
|
| 148 |
+
assert_true(
|
| 149 |
+
'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"' in content,
|
| 150 |
+
f"{shell_file.name} should contain the Copilot provider base URL",
|
| 151 |
+
)
|
| 152 |
+
assert_true(
|
| 153 |
+
'export COPILOT_PROVIDER_WIRE_API="completions"' in content,
|
| 154 |
+
f"{shell_file.name} should contain the Copilot wire API",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
copilot_calls = [
|
| 158 |
+
record["argv"] for record in read_jsonl(shim_log) if record["tool"] == "copilot"
|
| 159 |
+
]
|
| 160 |
+
assert_true(
|
| 161 |
+
copilot_calls
|
| 162 |
+
== [
|
| 163 |
+
["plugin", "marketplace", "add", str(REPO_ROOT)],
|
| 164 |
+
["plugin", "install", "headroom@headroom-marketplace"],
|
| 165 |
+
],
|
| 166 |
+
f"Unexpected Copilot install commands: {copilot_calls}",
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def verify_codex_local(home_dir: Path, project_dir: Path) -> None:
|
| 171 |
+
config_path = project_dir / ".codex" / "config.toml"
|
| 172 |
+
hooks_path = project_dir / ".codex" / "hooks.json"
|
| 173 |
+
config = config_path.read_text(encoding="utf-8")
|
| 174 |
+
hooks = json.loads(hooks_path.read_text(encoding="utf-8"))
|
| 175 |
+
profile = init_cli._local_profile(project_dir)
|
| 176 |
+
|
| 177 |
+
assert_true(
|
| 178 |
+
'base_url = "http://127.0.0.1:9012/v1"' in config,
|
| 179 |
+
"Codex config should point at the requested proxy port",
|
| 180 |
+
)
|
| 181 |
+
assert_true(
|
| 182 |
+
config.count("[features]") == 1, "Codex config should keep a single [features] table"
|
| 183 |
+
)
|
| 184 |
+
assert_true("codex_hooks = true" in config, "Codex config should enable codex_hooks")
|
| 185 |
+
command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
| 186 |
+
expect_hook_command(command, profile)
|
| 187 |
+
|
| 188 |
+
manifest = read_manifest(home_dir, profile)
|
| 189 |
+
targets = manifest["targets"]
|
| 190 |
+
assert_true(set(targets) == {"claude", "codex"}, f"Unexpected merged targets: {targets}")
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def main() -> None:
|
| 194 |
+
with tempfile.TemporaryDirectory(prefix="headroom-init-e2e-") as temp_root_raw:
|
| 195 |
+
temp_root = Path(temp_root_raw)
|
| 196 |
+
home_dir = temp_root / "home"
|
| 197 |
+
project_dir = temp_root / "project"
|
| 198 |
+
shim_dir = temp_root / "bin"
|
| 199 |
+
shim_log = temp_root / "shim-log.jsonl"
|
| 200 |
+
home_dir.mkdir(parents=True)
|
| 201 |
+
project_dir.mkdir(parents=True)
|
| 202 |
+
create_agent_shims(shim_dir, shim_log)
|
| 203 |
+
|
| 204 |
+
env = os.environ.copy()
|
| 205 |
+
env["HOME"] = str(home_dir)
|
| 206 |
+
env["USERPROFILE"] = str(home_dir)
|
| 207 |
+
env["HEADROOM_INIT_E2E_LOG"] = str(shim_log)
|
| 208 |
+
env["PATH"] = f"{shim_dir}:{env['PATH']}"
|
| 209 |
+
|
| 210 |
+
run([HEADROOM, "init", "--port", "9011", "claude"], env=env, cwd=project_dir)
|
| 211 |
+
verify_claude_local(home_dir, project_dir, shim_log)
|
| 212 |
+
|
| 213 |
+
run(
|
| 214 |
+
[
|
| 215 |
+
HEADROOM,
|
| 216 |
+
"init",
|
| 217 |
+
"-g",
|
| 218 |
+
"--port",
|
| 219 |
+
"9005",
|
| 220 |
+
"--backend",
|
| 221 |
+
"openai",
|
| 222 |
+
"copilot",
|
| 223 |
+
],
|
| 224 |
+
env=env,
|
| 225 |
+
cwd=project_dir,
|
| 226 |
+
)
|
| 227 |
+
verify_copilot_global(home_dir, shim_log)
|
| 228 |
+
|
| 229 |
+
run([HEADROOM, "init", "--port", "9012", "codex"], env=env, cwd=project_dir)
|
| 230 |
+
verify_codex_local(home_dir, project_dir)
|
| 231 |
+
|
| 232 |
+
log("Init e2e completed successfully")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
if __name__ == "__main__":
|
| 236 |
+
main()
|
scripts/tests/test_sync_plugin_versions.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for sync-plugin-versions.py."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import importlib.util
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _load_module():
|
| 10 |
+
script = Path(__file__).parent.parent / "sync-plugin-versions.py"
|
| 11 |
+
spec = importlib.util.spec_from_file_location("sync_plugin_versions", script)
|
| 12 |
+
assert spec is not None
|
| 13 |
+
assert spec.loader is not None
|
| 14 |
+
module = importlib.util.module_from_spec(spec)
|
| 15 |
+
spec.loader.exec_module(module)
|
| 16 |
+
return module
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_compute_repo_semver_uses_release_helpers(monkeypatch) -> None:
|
| 20 |
+
module = _load_module()
|
| 21 |
+
calls: dict[str, object] = {}
|
| 22 |
+
|
| 23 |
+
monkeypatch.setattr(module, "list_release_tags", lambda root: ["v0.9.0"])
|
| 24 |
+
monkeypatch.setattr(module, "find_latest_release_tag", lambda tags: "v0.9.0")
|
| 25 |
+
monkeypatch.setattr(module, "list_release_commits", lambda root, tag: ["feat: add init"])
|
| 26 |
+
monkeypatch.setattr(module, "determine_bump_level", lambda commits: "minor")
|
| 27 |
+
monkeypatch.setattr(module, "get_canonical_version", lambda root: "0.5.25")
|
| 28 |
+
|
| 29 |
+
def fake_compute_release_version(*, canonical_version: str, level: str, tags: list[str]):
|
| 30 |
+
calls["canonical_version"] = canonical_version
|
| 31 |
+
calls["level"] = level
|
| 32 |
+
calls["tags"] = tags
|
| 33 |
+
return type("Info", (), {"npm_version": "0.10.0"})()
|
| 34 |
+
|
| 35 |
+
monkeypatch.setattr(module, "compute_release_version", fake_compute_release_version)
|
| 36 |
+
|
| 37 |
+
assert module.compute_repo_semver(Path("repo")) == "0.10.0"
|
| 38 |
+
assert calls == {
|
| 39 |
+
"canonical_version": "0.5.25",
|
| 40 |
+
"level": "minor",
|
| 41 |
+
"tags": ["v0.9.0"],
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def test_main_runs_plugin_only_version_sync(monkeypatch) -> None:
|
| 46 |
+
module = _load_module()
|
| 47 |
+
commands: list[list[str]] = []
|
| 48 |
+
|
| 49 |
+
monkeypatch.setattr(module, "compute_repo_semver", lambda root: "0.10.0")
|
| 50 |
+
monkeypatch.setattr(
|
| 51 |
+
module.subprocess,
|
| 52 |
+
"run",
|
| 53 |
+
lambda command, cwd, check: commands.append(command),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
module.main()
|
| 57 |
+
|
| 58 |
+
assert commands == [
|
| 59 |
+
[
|
| 60 |
+
module.sys.executable,
|
| 61 |
+
str(module.ROOT / "scripts" / "version-sync.py"),
|
| 62 |
+
"--root",
|
| 63 |
+
str(module.ROOT),
|
| 64 |
+
"--version",
|
| 65 |
+
"0.10.0",
|
| 66 |
+
"--plugin-manifests-only",
|
| 67 |
+
]
|
| 68 |
+
]
|
tests/test_cli/test_init_cli.py
CHANGED
|
@@ -200,3 +200,23 @@ def test_detect_init_targets_respects_scope(monkeypatch) -> None:
|
|
| 200 |
|
| 201 |
assert init_cli.detect_init_targets(False) == ["claude", "codex"]
|
| 202 |
assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
assert init_cli.detect_init_targets(False) == ["claude", "codex"]
|
| 202 |
assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def test_marketplace_source_prefers_env_override(monkeypatch) -> None:
|
| 206 |
+
init_cli, _ = _load_init_module(monkeypatch)
|
| 207 |
+
monkeypatch.setenv("HEADROOM_MARKETPLACE_SOURCE", "custom/source")
|
| 208 |
+
|
| 209 |
+
assert init_cli._marketplace_source() == "custom/source"
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def test_run_checked_treats_existing_install_as_success(monkeypatch) -> None:
|
| 213 |
+
init_cli, _ = _load_init_module(monkeypatch)
|
| 214 |
+
|
| 215 |
+
class _Result:
|
| 216 |
+
returncode = 1
|
| 217 |
+
stderr = "plugin already exists"
|
| 218 |
+
stdout = ""
|
| 219 |
+
|
| 220 |
+
monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result())
|
| 221 |
+
|
| 222 |
+
init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install")
|