JerrettDavis Copilot commited on
Commit
a9a8a0e
·
1 Parent(s): e517426

test: cover init install flows end to end

Browse files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

.dockerignore CHANGED
@@ -1,63 +1,73 @@
1
- # VCS
2
- .git
3
- .github
4
- .gitignore
5
-
6
- # Python artifacts
7
- __pycache__
8
- *.pyc
9
- *.pyo
10
- *.egg-info
11
- dist/
12
- build/
13
-
14
- # Dev/test tooling
15
- .pytest_cache
16
- .coverage
17
- .mypy_cache
18
- .ruff_cache
19
- .pre-commit-config.yaml
20
- .venv
21
- venv
22
-
23
- # Tests & docs (not needed in image)
24
- tests/
25
- docs/
26
- mkdocs.yml
27
- CHANGELOG.md
28
- LICENSE
29
- NOTICE
30
-
31
- # JS/TS artifacts (dashboard, SDK — not part of proxy image)
32
- apps/
33
- sdk/
34
- !sdk/
35
- !sdk/typescript/
36
- !sdk/typescript/**
37
- plugins/
38
- !plugins/
39
- !plugins/openclaw/
40
- !plugins/openclaw/**
41
- node_modules/
42
- *.tgz
43
-
44
- # Secrets & local config
45
- .env
46
- .env.*
47
- *.log
48
-
49
- # IDE
50
- .vscode
51
- .idea
52
- *.swp
53
-
54
- # Docker
55
- Dockerfile
56
- docker-compose*.yml
57
- .dockerignore
58
-
59
- # Misc
60
- .pi-lens/
61
- .superpowers/
62
- examples/
63
- node-compile-cache/
 
 
 
 
 
 
 
 
 
 
 
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")