mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge pull request #256 from JerrettDavis/fix/init-g-regression
fix(init): guide users when no agents are auto-detected + expand e2e
This commit is contained in:
commit
7b99b05e0d
19 changed files with 2960 additions and 1751 deletions
|
|
@ -5,14 +5,14 @@
|
|||
},
|
||||
"metadata": {
|
||||
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
||||
"version": "0.11.2"
|
||||
"version": "0.12.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "headroom",
|
||||
"source": "./plugins/headroom-agent-hooks",
|
||||
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
||||
"version": "0.11.2",
|
||||
"version": "0.12.0",
|
||||
"author": {
|
||||
"name": "Headroom Contributors",
|
||||
"url": "https://github.com/chopratejas/headroom"
|
||||
|
|
|
|||
68
.github/actions/headroom-e2e-setup/action.yml
vendored
Normal file
68
.github/actions/headroom-e2e-setup/action.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
name: Headroom e2e setup
|
||||
description: >-
|
||||
Checkout-agnostic setup shared by native e2e workflows (init, install, wrap).
|
||||
Installs Python, installs headroom in editable mode, and (optionally) drops
|
||||
a noop shim onto PATH so ``headroom init -g <target>`` can detect a tool
|
||||
that isn't actually installed on the runner.
|
||||
inputs:
|
||||
python-version:
|
||||
description: Python version to install
|
||||
required: false
|
||||
default: "3.11"
|
||||
shim-target:
|
||||
description: >-
|
||||
Name of the shim to drop on PATH (e.g. ``claude``, ``codex``). Leave
|
||||
empty to skip shim creation.
|
||||
required: false
|
||||
default: ""
|
||||
outputs:
|
||||
shim-dir:
|
||||
description: Absolute path to the directory containing the dropped shim
|
||||
value: ${{ steps.shim.outputs.shim-dir }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Install headroom (editable, with proxy extras)
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# ``headroom/cli/__init__.py`` eagerly imports ``proxy.server`` (via
|
||||
# ``cli/proxy.py``), which requires ``fastapi`` even for ``init``.
|
||||
# Install with the ``[proxy]`` extras to match the Docker e2e image.
|
||||
pip install -e ".[proxy]"
|
||||
|
||||
- name: Drop shim (POSIX)
|
||||
if: ${{ inputs.shim-target != '' && runner.os != 'Windows' }}
|
||||
id: shim-posix
|
||||
shell: bash
|
||||
run: |
|
||||
shim_dir="${RUNNER_TEMP}/headroom-e2e-shims"
|
||||
bash e2e/_lib/make_shim.sh "${{ inputs.shim-target }}" "$shim_dir"
|
||||
echo "$shim_dir" >> "$GITHUB_PATH"
|
||||
echo "shim-dir=$shim_dir" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Drop shim (Windows)
|
||||
if: ${{ inputs.shim-target != '' && runner.os == 'Windows' }}
|
||||
id: shim-windows
|
||||
shell: pwsh
|
||||
run: |
|
||||
$shimDir = Join-Path $env:RUNNER_TEMP "headroom-e2e-shims"
|
||||
& pwsh -File e2e/_lib/make_shim.ps1 -Name "${{ inputs.shim-target }}" -Dir $shimDir
|
||||
Add-Content -Path $env:GITHUB_PATH -Value $shimDir
|
||||
"shim-dir=$shimDir" | Out-File -FilePath $env:GITHUB_OUTPUT -Append
|
||||
|
||||
- name: Export shim dir to job output
|
||||
if: ${{ inputs.shim-target != '' }}
|
||||
id: shim
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
echo "shim-dir=${{ steps.shim-windows.outputs.shim-dir }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "shim-dir=${{ steps.shim-posix.outputs.shim-dir }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
4
.github/plugin/marketplace.json
vendored
4
.github/plugin/marketplace.json
vendored
|
|
@ -5,14 +5,14 @@
|
|||
},
|
||||
"metadata": {
|
||||
"description": "Headroom marketplace for Claude Code and GitHub Copilot CLI plugins.",
|
||||
"version": "0.11.2"
|
||||
"version": "0.12.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "headroom",
|
||||
"source": "./plugins/headroom-agent-hooks",
|
||||
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
||||
"version": "0.11.2",
|
||||
"version": "0.12.0",
|
||||
"author": {
|
||||
"name": "Headroom Contributors",
|
||||
"url": "https://github.com/chopratejas/headroom"
|
||||
|
|
|
|||
132
.github/workflows/init-native-e2e.yml
vendored
Normal file
132
.github/workflows/init-native-e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
name: Init Native E2E
|
||||
|
||||
# Cross-platform (linux / macos / windows) smoke tests for the per-subcommand
|
||||
# ``headroom init -g <target>`` flows. Each matrix cell drops a noop shim for
|
||||
# the target agent onto PATH and asserts ``headroom init -g <target>``
|
||||
# succeeds, writes the expected settings file, and (for claude/codex) places
|
||||
# hooks in the right place.
|
||||
#
|
||||
# Deliberately scoped to pull_request + push-to-main + workflow_dispatch to
|
||||
# avoid bloating CI minutes on every push to every feature branch. The Docker
|
||||
# init-e2e.yml still runs on every PR and provides the deeper functional
|
||||
# coverage; this workflow exists to catch platform-specific bugs (Windows
|
||||
# path separators, macos keychain prompts, PowerShell-vs-bash hook matchers)
|
||||
# that the single-platform Docker suite can miss.
|
||||
#
|
||||
# Extending to other commands (``headroom install``, ``headroom wrap``) is
|
||||
# expected to be a near-copy of this file. The shared composite action at
|
||||
# ``.github/actions/headroom-e2e-setup`` absorbs the Python + shim setup so
|
||||
# each per-command workflow only supplies its matrix and assertion steps.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "headroom/cli/init.py"
|
||||
- "headroom/install/**"
|
||||
- "e2e/_lib/**"
|
||||
- "e2e/init/**"
|
||||
- ".github/actions/headroom-e2e-setup/**"
|
||||
- ".github/workflows/init-native-e2e.yml"
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
init-native:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
target: [claude, codex, copilot, openclaw]
|
||||
exclude:
|
||||
# openclaw delegates to ``headroom wrap openclaw`` which needs a
|
||||
# running OpenClaw CLI; it can't be shimmed cheaply, so it's
|
||||
# covered by the bundled Docker e2e instead.
|
||||
- target: openclaw
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup (shim=${{ matrix.target }})
|
||||
uses: ./.github/actions/headroom-e2e-setup
|
||||
with:
|
||||
python-version: "3.11"
|
||||
shim-target: ${{ matrix.target }}
|
||||
|
||||
- name: Verify shim is on PATH (POSIX)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
which "${{ matrix.target }}"
|
||||
|
||||
- name: Verify shim is on PATH (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# On Windows the shim is ``<target>.cmd``; Get-Command resolves via
|
||||
# PATHEXT (same as Python's ``shutil.which`` used by headroom init).
|
||||
# Git Bash's ``which`` cannot find ``.cmd`` shims, so we use pwsh.
|
||||
$cmd = Get-Command "${{ matrix.target }}" -ErrorAction Stop
|
||||
Write-Output $cmd.Source
|
||||
|
||||
- name: Run headroom init -g ${{ matrix.target }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
headroom init -g "${{ matrix.target }}"
|
||||
|
||||
- name: Assert settings file (POSIX)
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ matrix.target }}" in
|
||||
claude)
|
||||
test -f "$HOME/.claude/settings.json"
|
||||
grep -q "ANTHROPIC_BASE_URL" "$HOME/.claude/settings.json"
|
||||
;;
|
||||
codex)
|
||||
test -f "$HOME/.codex/config.toml"
|
||||
test -f "$HOME/.codex/hooks.json"
|
||||
grep -q "headroom" "$HOME/.codex/config.toml"
|
||||
;;
|
||||
copilot)
|
||||
test -f "$HOME/.copilot/config.json"
|
||||
grep -q "SessionStart" "$HOME/.copilot/config.json"
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Assert settings file (Windows)
|
||||
if: runner.os == 'Windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$home_ = $env:USERPROFILE
|
||||
switch ("${{ matrix.target }}") {
|
||||
"claude" {
|
||||
$p = Join-Path $home_ ".claude\settings.json"
|
||||
if (-not (Test-Path $p)) { throw "Missing $p" }
|
||||
if (-not ((Get-Content $p -Raw) -match "ANTHROPIC_BASE_URL")) {
|
||||
throw "settings.json missing ANTHROPIC_BASE_URL"
|
||||
}
|
||||
}
|
||||
"codex" {
|
||||
$c = Join-Path $home_ ".codex\config.toml"
|
||||
$h = Join-Path $home_ ".codex\hooks.json"
|
||||
if (-not (Test-Path $c)) { throw "Missing $c" }
|
||||
if (-not (Test-Path $h)) { throw "Missing $h" }
|
||||
if (-not ((Get-Content $c -Raw) -match "headroom")) {
|
||||
throw "config.toml missing headroom provider"
|
||||
}
|
||||
}
|
||||
"copilot" {
|
||||
$p = Join-Path $home_ ".copilot\config.json"
|
||||
if (-not (Test-Path $p)) { throw "Missing $p" }
|
||||
if (-not ((Get-Content $p -Raw) -match "SessionStart")) {
|
||||
throw "copilot config missing SessionStart hooks"
|
||||
}
|
||||
}
|
||||
}
|
||||
7
e2e/__init__.py
Normal file
7
e2e/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
"""End-to-end test suites for Headroom CLI commands.
|
||||
|
||||
Subpackages:
|
||||
_lib — shared harness and helpers
|
||||
init — ``headroom init`` coverage
|
||||
wrap — ``headroom wrap`` coverage
|
||||
"""
|
||||
35
e2e/_lib/__init__.py
Normal file
35
e2e/_lib/__init__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Shared helpers for Docker / CI e2e tests.
|
||||
|
||||
This package centralizes utilities used by the per-command e2e harnesses
|
||||
(`e2e/init/run.py`, future `e2e/install/run.py`, `e2e/wrap/run.py`, ...).
|
||||
The goal is that each command test suite is a small declarative file that
|
||||
imports from this package, so new commands can be covered with minimal
|
||||
duplication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .assertions import (
|
||||
assert_exit,
|
||||
assert_stderr_contains,
|
||||
assert_stdout_contains,
|
||||
read_agent_settings,
|
||||
)
|
||||
from .harness import Case, CaseContext, run_case_sequence, run_cases
|
||||
from .path_env import with_clean_path
|
||||
from .paths import agent_settings_path
|
||||
from .shims import make_shim
|
||||
|
||||
__all__ = [
|
||||
"Case",
|
||||
"CaseContext",
|
||||
"agent_settings_path",
|
||||
"assert_exit",
|
||||
"assert_stderr_contains",
|
||||
"assert_stdout_contains",
|
||||
"make_shim",
|
||||
"read_agent_settings",
|
||||
"run_case_sequence",
|
||||
"run_cases",
|
||||
"with_clean_path",
|
||||
]
|
||||
43
e2e/_lib/assertions.py
Normal file
43
e2e/_lib/assertions.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
"""Shared assertion helpers for e2e cases.
|
||||
|
||||
Assertions raise ``AssertionError`` with a descriptive message. The harness
|
||||
catches them and attributes the failure to the owning ``Case``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .paths import Agent, Scope, agent_settings_path
|
||||
|
||||
|
||||
def assert_exit(actual: int, expected: int, *, context: str = "") -> None:
|
||||
if actual != expected:
|
||||
suffix = f" ({context})" if context else ""
|
||||
raise AssertionError(f"Expected exit code {expected}, got {actual}{suffix}")
|
||||
|
||||
|
||||
def assert_stdout_contains(stdout: str, needle: str) -> None:
|
||||
if needle not in stdout:
|
||||
raise AssertionError(f"stdout missing {needle!r}:\n---\n{stdout}\n---")
|
||||
|
||||
|
||||
def assert_stderr_contains(stderr: str, needle: str) -> None:
|
||||
if needle not in stderr:
|
||||
raise AssertionError(f"stderr missing {needle!r}:\n---\n{stderr}\n---")
|
||||
|
||||
|
||||
def read_agent_settings(
|
||||
agent: Agent, *, scope: Scope, home: Path, project: Path
|
||||
) -> dict[str, Any] | str:
|
||||
"""Read an agent's settings file, returning dict for JSON and str for TOML/other."""
|
||||
|
||||
path = agent_settings_path(agent, scope=scope, home=home, project=project)
|
||||
if not path.exists():
|
||||
raise AssertionError(f"Expected settings file at {path}, not found")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
if path.suffix == ".json":
|
||||
return json.loads(text)
|
||||
return text
|
||||
336
e2e/_lib/harness.py
Normal file
336
e2e/_lib/harness.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
"""Declarative test-case harness for Docker e2e runners.
|
||||
|
||||
Each command gets its own ``run.py`` file that builds a list of ``Case``
|
||||
objects and calls ``run_cases(cases)``. The harness handles:
|
||||
|
||||
* creating a scratch HOME and project directory per case
|
||||
* dropping the requested shims into a dedicated shim dir
|
||||
* building a clean PATH that only exposes the shim dir + minimal system dirs
|
||||
* invoking the ``headroom`` subprocess with the case's argv
|
||||
* running the case's assertions against stdout / stderr / exit code / files
|
||||
* reporting pass/fail per case and a final summary
|
||||
|
||||
``run_cases`` returns a non-zero exit code if any case fails, so Docker
|
||||
containers driving it can fail fast.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from .assertions import assert_exit, assert_stderr_contains, assert_stdout_contains
|
||||
from .path_env import with_clean_path
|
||||
from .shims import ShimBehavior, make_shim
|
||||
|
||||
CaseCallback = Callable[["CaseContext"], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaseContext:
|
||||
"""Runtime context passed to assertion callbacks."""
|
||||
|
||||
name: str
|
||||
home: Path
|
||||
project: Path
|
||||
shim_dir: Path
|
||||
shim_log: Path
|
||||
stdout: str
|
||||
stderr: str
|
||||
exit_code: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class Case:
|
||||
"""Declarative specification of a single e2e test case.
|
||||
|
||||
Attributes:
|
||||
name: Human-readable identifier, printed on success/failure.
|
||||
argv: Arguments passed to ``headroom`` (e.g. ``["init", "-g", "claude"]``).
|
||||
shims: Mapping of shim name -> behavior to drop into the shim dir.
|
||||
env_extra: Extra env vars layered on top of the clean env.
|
||||
expected_exit: Required exit code (default 0).
|
||||
expected_stdout_contains: Substrings that must appear on stdout.
|
||||
expected_stderr_contains: Substrings that must appear on stderr.
|
||||
expected_files: Paths (relative to home or project) that must exist.
|
||||
Use ``{home}/...`` or ``{project}/...`` placeholders.
|
||||
extra_assertions: Optional list of callbacks invoked after exit-code /
|
||||
stdout / stderr / file checks pass. Receives a
|
||||
``CaseContext``. Use for JSON-content assertions,
|
||||
shim-log inspection, etc.
|
||||
"""
|
||||
|
||||
name: str
|
||||
argv: list[str]
|
||||
shims: dict[str, ShimBehavior] = field(default_factory=dict)
|
||||
env_extra: dict[str, str] = field(default_factory=dict)
|
||||
expected_exit: int = 0
|
||||
expected_stdout_contains: list[str] = field(default_factory=list)
|
||||
expected_stderr_contains: list[str] = field(default_factory=list)
|
||||
expected_files: list[str] = field(default_factory=list)
|
||||
extra_assertions: list[CaseCallback] = field(default_factory=list)
|
||||
|
||||
|
||||
def _log(message: str) -> None:
|
||||
print(f"[e2e] {message}", flush=True)
|
||||
|
||||
|
||||
def _resolve_placeholder(spec: str, *, home: Path, project: Path) -> Path:
|
||||
return Path(spec.format(home=str(home), project=str(project)))
|
||||
|
||||
|
||||
def _resolve_headroom_bin(name: str) -> str:
|
||||
"""Return the absolute path to the headroom binary before PATH is scrubbed.
|
||||
|
||||
``with_clean_path`` intentionally narrows PATH so agent shims dominate;
|
||||
that would also hide the real ``headroom`` binary (typically at
|
||||
``/opt/*venv/bin/headroom`` or similar). Resolving up-front lets the
|
||||
subprocess launch even after PATH is cleaned.
|
||||
"""
|
||||
|
||||
if os.sep in name or (os.altsep and os.altsep in name):
|
||||
return name
|
||||
import shutil
|
||||
|
||||
resolved = shutil.which(name)
|
||||
if resolved:
|
||||
return resolved
|
||||
# Fall back to the bare name; subprocess will raise a clear
|
||||
# FileNotFoundError that the case output surfaces.
|
||||
return name
|
||||
|
||||
|
||||
def _run_single(case: Case, headroom_bin: str = "headroom") -> bool:
|
||||
"""Execute one case. Return True on pass, False on fail."""
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{case.name}-") as temp_raw:
|
||||
temp_root = Path(temp_raw)
|
||||
home = temp_root / "home"
|
||||
project = temp_root / "project"
|
||||
shim_dir = temp_root / "bin"
|
||||
shim_log = temp_root / "shim-log.jsonl"
|
||||
home.mkdir(parents=True)
|
||||
project.mkdir(parents=True)
|
||||
|
||||
for shim_name, behavior in case.shims.items():
|
||||
make_shim(shim_name, shim_dir, behavior=behavior)
|
||||
|
||||
# Resolve headroom to its absolute path BEFORE mutating PATH so the
|
||||
# shim dir can dominate PATH without losing the headroom binary.
|
||||
resolved_bin = _resolve_headroom_bin(headroom_bin)
|
||||
|
||||
with with_clean_path([shim_dir]) as env:
|
||||
env["HOME"] = str(home)
|
||||
env["USERPROFILE"] = str(home)
|
||||
env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log)
|
||||
env.update(case.env_extra)
|
||||
|
||||
proc = subprocess.run(
|
||||
[resolved_bin, *case.argv],
|
||||
env=env,
|
||||
cwd=str(project),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
ctx = CaseContext(
|
||||
name=case.name,
|
||||
home=home,
|
||||
project=project,
|
||||
shim_dir=shim_dir,
|
||||
shim_log=shim_log,
|
||||
stdout=proc.stdout,
|
||||
stderr=proc.stderr,
|
||||
exit_code=proc.returncode,
|
||||
)
|
||||
|
||||
try:
|
||||
assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}")
|
||||
for needle in case.expected_stdout_contains:
|
||||
assert_stdout_contains(proc.stdout, needle)
|
||||
for needle in case.expected_stderr_contains:
|
||||
assert_stderr_contains(proc.stderr, needle)
|
||||
for spec in case.expected_files:
|
||||
path = _resolve_placeholder(spec, home=home, project=project)
|
||||
if not path.exists():
|
||||
raise AssertionError(f"Expected file {path} not found")
|
||||
for callback in case.extra_assertions:
|
||||
callback(ctx)
|
||||
except AssertionError as exc:
|
||||
_log(f"FAIL {case.name}: {exc}")
|
||||
if proc.stdout.strip():
|
||||
_log(f" stdout: {proc.stdout.rstrip()}")
|
||||
if proc.stderr.strip():
|
||||
_log(f" stderr: {proc.stderr.rstrip()}")
|
||||
return False
|
||||
|
||||
_log(f"PASS {case.name}")
|
||||
return True
|
||||
|
||||
|
||||
def _run_in_scratch(
|
||||
case: Case,
|
||||
*,
|
||||
home: Path,
|
||||
project: Path,
|
||||
shim_dir: Path,
|
||||
shim_log: Path,
|
||||
headroom_bin: str,
|
||||
) -> bool:
|
||||
"""Execute one case inside a pre-existing scratch layout.
|
||||
|
||||
Shims are *added* to ``shim_dir`` (existing shims from prior sequence
|
||||
steps are preserved). This enables sequence cases to build up shim state.
|
||||
"""
|
||||
|
||||
for shim_name, behavior in case.shims.items():
|
||||
make_shim(shim_name, shim_dir, behavior=behavior)
|
||||
|
||||
resolved_bin = _resolve_headroom_bin(headroom_bin)
|
||||
|
||||
with with_clean_path([shim_dir]) as env:
|
||||
env["HOME"] = str(home)
|
||||
env["USERPROFILE"] = str(home)
|
||||
env["HEADROOM_E2E_SHIM_LOG"] = str(shim_log)
|
||||
env.update(case.env_extra)
|
||||
|
||||
proc = subprocess.run(
|
||||
[resolved_bin, *case.argv],
|
||||
env=env,
|
||||
cwd=str(project),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
ctx = CaseContext(
|
||||
name=case.name,
|
||||
home=home,
|
||||
project=project,
|
||||
shim_dir=shim_dir,
|
||||
shim_log=shim_log,
|
||||
stdout=proc.stdout,
|
||||
stderr=proc.stderr,
|
||||
exit_code=proc.returncode,
|
||||
)
|
||||
|
||||
try:
|
||||
assert_exit(proc.returncode, case.expected_exit, context=f"case {case.name}")
|
||||
for needle in case.expected_stdout_contains:
|
||||
assert_stdout_contains(proc.stdout, needle)
|
||||
for needle in case.expected_stderr_contains:
|
||||
assert_stderr_contains(proc.stderr, needle)
|
||||
for spec in case.expected_files:
|
||||
path = _resolve_placeholder(spec, home=home, project=project)
|
||||
if not path.exists():
|
||||
raise AssertionError(f"Expected file {path} not found")
|
||||
for callback in case.extra_assertions:
|
||||
callback(ctx)
|
||||
except AssertionError as exc:
|
||||
_log(f"FAIL {case.name}: {exc}")
|
||||
if proc.stdout.strip():
|
||||
_log(f" stdout: {proc.stdout.rstrip()}")
|
||||
if proc.stderr.strip():
|
||||
_log(f" stderr: {proc.stderr.rstrip()}")
|
||||
return False
|
||||
|
||||
_log(f"PASS {case.name}")
|
||||
return True
|
||||
|
||||
|
||||
def run_cases(
|
||||
cases: list[Case],
|
||||
*,
|
||||
headroom_bin: str = "headroom",
|
||||
fail_fast: bool = False,
|
||||
) -> int:
|
||||
"""Run each case in its own scratch dir. Return exit code (0 = all pass)."""
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
for case in cases:
|
||||
ok = _run_single(case, headroom_bin=headroom_bin)
|
||||
if ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
if fail_fast:
|
||||
break
|
||||
|
||||
_log(f"Summary: {passed} passed, {failed} failed, {len(cases)} total")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
def run_case_sequence(
|
||||
cases: list[Case],
|
||||
*,
|
||||
headroom_bin: str = "headroom",
|
||||
label: str = "sequence",
|
||||
fail_fast: bool = True,
|
||||
) -> int:
|
||||
"""Run cases sequentially inside a single shared scratch dir.
|
||||
|
||||
Useful when later cases must observe state left by earlier ones (e.g.
|
||||
``headroom init`` accumulating targets in a shared manifest across
|
||||
successive calls).
|
||||
"""
|
||||
|
||||
passed = 0
|
||||
failed = 0
|
||||
with tempfile.TemporaryDirectory(prefix=f"headroom-e2e-{label}-") as temp_raw:
|
||||
temp_root = Path(temp_raw)
|
||||
home = temp_root / "home"
|
||||
project = temp_root / "project"
|
||||
shim_dir = temp_root / "bin"
|
||||
shim_log = temp_root / "shim-log.jsonl"
|
||||
home.mkdir(parents=True)
|
||||
project.mkdir(parents=True)
|
||||
|
||||
for case in cases:
|
||||
ok = _run_in_scratch(
|
||||
case,
|
||||
home=home,
|
||||
project=project,
|
||||
shim_dir=shim_dir,
|
||||
shim_log=shim_log,
|
||||
headroom_bin=headroom_bin,
|
||||
)
|
||||
if ok:
|
||||
passed += 1
|
||||
else:
|
||||
failed += 1
|
||||
if fail_fast:
|
||||
break
|
||||
|
||||
_log(f"Summary ({label}): {passed} passed, {failed} failed, {len(cases)} total")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
# Allow callers to adopt a different exit strategy (e.g. raising) easily.
|
||||
def main_from_cases(cases: list[Case]) -> None:
|
||||
"""Convenience entry point for ``run.py`` scripts."""
|
||||
|
||||
code = run_cases(cases)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Case",
|
||||
"CaseContext",
|
||||
"main_from_cases",
|
||||
"run_case_sequence",
|
||||
"run_cases",
|
||||
]
|
||||
|
||||
# Silence unused-import lint for re-exports used by callers.
|
||||
_ = os
|
||||
24
e2e/_lib/make_shim.ps1
Normal file
24
e2e/_lib/make_shim.ps1
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Create a noop executable shim at $Dir\$Name.cmd for use in PATH during
|
||||
# native (non-Docker) e2e tests on Windows. Mirrors e2e/_lib/shims.py
|
||||
# make_shim(noop).
|
||||
#
|
||||
# Usage: make_shim.ps1 -Name <name> -Dir <dir>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[Parameter(Mandatory = $true)][string]$Dir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path $Dir)) {
|
||||
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
|
||||
}
|
||||
|
||||
$path = Join-Path $Dir "$Name.cmd"
|
||||
$content = @"
|
||||
@echo off
|
||||
exit /b 0
|
||||
"@
|
||||
Set-Content -Path $path -Value $content -Encoding ASCII -NoNewline
|
||||
Write-Output $path
|
||||
28
e2e/_lib/make_shim.sh
Normal file
28
e2e/_lib/make_shim.sh
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
#!/usr/bin/env bash
|
||||
# Create a noop executable shim at $2/$1 suitable for use in PATH during
|
||||
# native (non-Docker) e2e tests. Mirrors e2e/_lib/shims.py make_shim(noop).
|
||||
#
|
||||
# Usage: make_shim.sh <name> <dir>
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 on success
|
||||
# 2 on usage error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "usage: $0 <name> <dir>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
name="$1"
|
||||
dir="$2"
|
||||
|
||||
mkdir -p "$dir"
|
||||
path="$dir/$name"
|
||||
cat >"$path" <<'EOS'
|
||||
#!/usr/bin/env bash
|
||||
exit 0
|
||||
EOS
|
||||
chmod +x "$path"
|
||||
echo "$path"
|
||||
54
e2e/_lib/path_env.py
Normal file
54
e2e/_lib/path_env.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""PATH environment helpers for e2e test isolation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _minimal_path_dirs() -> list[str]:
|
||||
"""Directories always needed so Python / basic shell utilities work."""
|
||||
|
||||
if os.name == "nt":
|
||||
system_root = os.environ.get("SystemRoot", r"C:\Windows")
|
||||
return [
|
||||
rf"{system_root}\System32",
|
||||
system_root,
|
||||
rf"{system_root}\System32\Wbem",
|
||||
rf"{system_root}\System32\WindowsPowerShell\v1.0",
|
||||
]
|
||||
# POSIX: keep enough for bash, python3, mkdir, chmod, etc.
|
||||
return ["/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin"]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def with_clean_path(extra_dirs: list[Path] | None = None) -> Iterator[dict[str, str]]:
|
||||
"""Set PATH to a minimal known-good value plus ``extra_dirs``.
|
||||
|
||||
Yields the (already-mutated) environment dict so callers can pass it
|
||||
directly to ``subprocess.run(env=...)``. On exit, the previous PATH is
|
||||
restored.
|
||||
"""
|
||||
|
||||
extras = [str(Path(p)) for p in (extra_dirs or [])]
|
||||
new_path = os.pathsep.join(extras + _minimal_path_dirs())
|
||||
env = os.environ.copy()
|
||||
previous = env.get("PATH")
|
||||
env["PATH"] = new_path
|
||||
# Also mutate the real environment so ``shutil.which`` inside this process
|
||||
# sees the clean PATH. Restore on exit.
|
||||
real_previous = os.environ.get("PATH")
|
||||
os.environ["PATH"] = new_path
|
||||
try:
|
||||
yield env
|
||||
finally:
|
||||
if real_previous is None:
|
||||
os.environ.pop("PATH", None)
|
||||
else:
|
||||
os.environ["PATH"] = real_previous
|
||||
if previous is None:
|
||||
env.pop("PATH", None)
|
||||
else:
|
||||
env["PATH"] = previous
|
||||
47
e2e/_lib/paths.py
Normal file
47
e2e/_lib/paths.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""Per-agent settings-file locators for e2e assertions.
|
||||
|
||||
These paths mirror the logic in ``headroom.cli.init`` so e2e tests can
|
||||
verify that the right file was written without importing private init
|
||||
helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
Agent = Literal["claude", "codex", "copilot", "openclaw"]
|
||||
Scope = Literal["user", "local"]
|
||||
|
||||
|
||||
def agent_settings_path(agent: Agent, *, scope: Scope, home: Path, project: Path) -> Path:
|
||||
"""Return the file that ``headroom init`` should have written for ``agent``.
|
||||
|
||||
``home`` is the test's simulated HOME directory and ``project`` is the cwd
|
||||
used when invoking ``headroom init``. For global (``-g``) invocations only
|
||||
``home`` matters; for local invocations only ``project`` matters.
|
||||
"""
|
||||
|
||||
home = Path(home)
|
||||
project = Path(project)
|
||||
|
||||
if agent == "claude":
|
||||
if scope == "user":
|
||||
return home / ".claude" / "settings.json"
|
||||
return project / ".claude" / "settings.local.json"
|
||||
|
||||
if agent == "codex":
|
||||
if scope == "user":
|
||||
return home / ".codex" / "config.toml"
|
||||
return project / ".codex" / "config.toml"
|
||||
|
||||
if agent == "copilot":
|
||||
# Copilot init requires -g; no local scope.
|
||||
return home / ".copilot" / "config.json"
|
||||
|
||||
if agent == "openclaw":
|
||||
# OpenClaw init is delegated to `headroom wrap openclaw`; it writes
|
||||
# the openclaw json under $HOME.
|
||||
return home / ".openclaw" / "openclaw.json"
|
||||
|
||||
raise ValueError(f"Unknown agent: {agent!r}")
|
||||
96
e2e/_lib/shims.py
Normal file
96
e2e/_lib/shims.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Cross-platform agent binary shim factory for e2e tests.
|
||||
|
||||
A "shim" is a tiny executable with a given name (e.g. `claude`, `codex`) that
|
||||
the harness drops into a temporary directory and prepends to PATH. It lets
|
||||
tests drive `headroom init` without requiring a real Claude/Codex install.
|
||||
|
||||
Three behaviors are supported:
|
||||
|
||||
* ``noop`` — exits 0 with no output. Default.
|
||||
* ``fail`` — exits 1 with a short stderr message.
|
||||
* ``record-args`` — appends a JSON record of (tool, argv, cwd) to the file at
|
||||
``$HEADROOM_E2E_SHIM_LOG``, then exits 0. Useful for
|
||||
asserting that `init claude` invoked
|
||||
`claude plugin install` with the right arguments.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
ShimBehavior = Literal["noop", "fail", "record-args"]
|
||||
|
||||
_NOOP_SH = """#!/usr/bin/env bash
|
||||
exit 0
|
||||
"""
|
||||
|
||||
_FAIL_SH = """#!/usr/bin/env bash
|
||||
echo "${0##*/}: simulated failure" >&2
|
||||
exit 1
|
||||
"""
|
||||
|
||||
_RECORD_SH = """#!/usr/bin/env bash
|
||||
tool="${0##*/}"
|
||||
log="${HEADROOM_E2E_SHIM_LOG:-/dev/null}"
|
||||
mkdir -p "$(dirname "$log")" 2>/dev/null || true
|
||||
python3 - "$tool" "$log" "$@" <<'PY'
|
||||
import json, os, sys
|
||||
tool, log, *argv = sys.argv[1:]
|
||||
record = {"tool": tool, "argv": argv, "cwd": os.getcwd()}
|
||||
if log != "/dev/null":
|
||||
with open(log, "a", encoding="utf-8") as handle:
|
||||
handle.write(json.dumps(record) + "\\n")
|
||||
print(f"{tool} shim executed")
|
||||
PY
|
||||
exit 0
|
||||
"""
|
||||
|
||||
# Windows equivalents. Use `.cmd` so `shutil.which` and PATHEXT find them.
|
||||
_NOOP_CMD = "@echo off\r\nexit /b 0\r\n"
|
||||
|
||||
_FAIL_CMD = "@echo off\r\necho %~n0: simulated failure 1>&2\r\nexit /b 1\r\n"
|
||||
|
||||
_RECORD_CMD = (
|
||||
"@echo off\r\n"
|
||||
"setlocal\r\n"
|
||||
'if "%HEADROOM_E2E_SHIM_LOG%"=="" set HEADROOM_E2E_SHIM_LOG=NUL\r\n'
|
||||
"python -c \"import json,os,sys; name=r'%~n0'; log=os.environ['HEADROOM_E2E_SHIM_LOG']; "
|
||||
"rec={'tool':name,'argv':sys.argv[1:],'cwd':os.getcwd()};\r\n"
|
||||
"open(log,'a',encoding='utf-8').write(json.dumps(rec)+chr(10)) if log!='NUL' else None;\r\n"
|
||||
"print(f'{name} shim executed')\" %*\r\n"
|
||||
"exit /b 0\r\n"
|
||||
)
|
||||
|
||||
|
||||
def _is_windows() -> bool:
|
||||
return os.name == "nt" or sys.platform == "win32"
|
||||
|
||||
|
||||
def make_shim(name: str, dir: Path, behavior: ShimBehavior = "noop") -> Path:
|
||||
"""Create an executable shim named ``name`` inside ``dir``.
|
||||
|
||||
Returns the absolute path to the created shim. On POSIX this is a ``.sh``
|
||||
file made executable and named without extension (so ``shutil.which(name)``
|
||||
finds it). On Windows this is a ``.cmd`` file — again, ``shutil.which``
|
||||
honours ``PATHEXT`` and will find it.
|
||||
"""
|
||||
|
||||
dir = Path(dir)
|
||||
dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if _is_windows():
|
||||
body = {"noop": _NOOP_CMD, "fail": _FAIL_CMD, "record-args": _RECORD_CMD}[behavior]
|
||||
path = dir / f"{name}.cmd"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
return path
|
||||
|
||||
body = {"noop": _NOOP_SH, "fail": _FAIL_SH, "record-args": _RECORD_SH}[behavior]
|
||||
path = dir / name
|
||||
path.write_text(body, encoding="utf-8")
|
||||
mode = path.stat().st_mode
|
||||
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
return path
|
||||
|
|
@ -24,10 +24,15 @@ COPY headroom ./headroom
|
|||
COPY .claude-plugin ./.claude-plugin
|
||||
COPY .github/plugin ./.github/plugin
|
||||
COPY plugins/headroom-agent-hooks ./plugins/headroom-agent-hooks
|
||||
# The init e2e harness imports from e2e._lib; both directories must be
|
||||
# present and each must contain an __init__.py so Python sees them as
|
||||
# packages rooted at /workspace.
|
||||
COPY e2e/__init__.py ./e2e/__init__.py
|
||||
COPY e2e/_lib ./e2e/_lib
|
||||
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 --upgrade "pip<25" && \
|
||||
/opt/headroom-venv/bin/python -m pip install -e ".[proxy]"
|
||||
|
||||
CMD ["python", "e2e/init/run.py"]
|
||||
|
|
|
|||
572
e2e/init/run.py
572
e2e/init/run.py
|
|
@ -1,236 +1,336 @@
|
|||
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()
|
||||
"""Docker e2e cases for ``headroom init``.
|
||||
|
||||
Every case is described declaratively with :class:`Case` from
|
||||
``e2e/_lib/harness.py``. Three groups run in order:
|
||||
|
||||
1. **existing sequence**: preserves the original scenario that exercised
|
||||
``headroom init claude`` (local) -> ``init -g copilot`` (global) ->
|
||||
``init codex`` (local), sharing scratch state so manifest-merge is
|
||||
exercised end-to-end.
|
||||
2. **bare ``init -g`` detection**: verifies the UX regression from #245
|
||||
stays fixed — both "no shims found" (friendly error, exit 1) and
|
||||
"all shims found" (exit 0, all four agents configured).
|
||||
3. **per-subcommand**: one case per ``init -g <agent>`` with only that
|
||||
agent's shim on PATH, so the explicit path is covered independently.
|
||||
|
||||
The fourth group covers ``--verbose`` output going to stderr.
|
||||
|
||||
Run directly: ``python e2e/init/run.py`` (inside the Docker image built
|
||||
from ``e2e/init/Dockerfile``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add repo root to sys.path so the harness import works whether the file is
|
||||
# invoked as ``python e2e/init/run.py`` or ``python -m e2e.init.run``.
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from e2e._lib import ( # noqa: E402
|
||||
Case,
|
||||
CaseContext,
|
||||
run_case_sequence,
|
||||
run_cases,
|
||||
)
|
||||
from headroom.cli import init as init_cli # noqa: E402
|
||||
|
||||
# ----- helpers reused across cases --------------------------------------------
|
||||
|
||||
# Docker image builds the workspace at /workspace; the marketplace source
|
||||
# falls back to that repo checkout when a local marketplace manifest is found.
|
||||
REPO_ROOT_IN_CONTAINER = Path("/workspace")
|
||||
|
||||
|
||||
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 _expect_hook_command(command: str, profile: str) -> None:
|
||||
if "init hook ensure" not in command:
|
||||
raise AssertionError(f"missing 'init hook ensure' in: {command}")
|
||||
if f"--profile {profile}" not in command:
|
||||
raise AssertionError(f"missing '--profile {profile}' in: {command}")
|
||||
|
||||
|
||||
def _read_manifest(home: Path, profile: str) -> dict[str, object]:
|
||||
path = home / ".headroom" / "deploy" / profile / "manifest.json"
|
||||
if not path.exists():
|
||||
raise AssertionError(f"Expected manifest at {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
# ----- existing-flow assertions (ported verbatim from the old run.py) ---------
|
||||
|
||||
|
||||
def _verify_claude_local(ctx: CaseContext) -> None:
|
||||
settings_path = ctx.project / ".claude" / "settings.local.json"
|
||||
settings = json.loads(settings_path.read_text(encoding="utf-8"))
|
||||
if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:9011":
|
||||
raise AssertionError(
|
||||
f"Claude local settings should point at port 9011, got "
|
||||
f"{settings['env']['ANTHROPIC_BASE_URL']!r}"
|
||||
)
|
||||
session_start = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
pre_tool = settings["hooks"]["PreToolUse"][0]["hooks"][0]["command"]
|
||||
profile = init_cli._local_profile(ctx.project)
|
||||
_expect_hook_command(session_start, profile)
|
||||
_expect_hook_command(pre_tool, profile)
|
||||
|
||||
manifest = _read_manifest(ctx.home, profile)
|
||||
if "claude" not in manifest["targets"]:
|
||||
raise AssertionError(
|
||||
f"Claude init should register the claude target, got {manifest['targets']}"
|
||||
)
|
||||
|
||||
claude_calls = [
|
||||
record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "claude"
|
||||
]
|
||||
expected = [
|
||||
["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)],
|
||||
["plugin", "install", "headroom@headroom-marketplace", "--scope", "local"],
|
||||
]
|
||||
if claude_calls != expected:
|
||||
raise AssertionError(f"Unexpected Claude install commands: {claude_calls}")
|
||||
|
||||
|
||||
def _verify_copilot_global(ctx: CaseContext) -> None:
|
||||
config = json.loads((ctx.home / ".copilot" / "config.json").read_text(encoding="utf-8"))
|
||||
if "SessionStart" not in config["hooks"]:
|
||||
raise AssertionError("Copilot config missing SessionStart hooks")
|
||||
if "PreToolUse" not in config["hooks"]:
|
||||
raise AssertionError("Copilot config missing PreToolUse hooks")
|
||||
session_start = config["hooks"]["SessionStart"][0]["command"]
|
||||
_expect_hook_command(session_start, "init-user")
|
||||
|
||||
for shell_file in (ctx.home / ".bashrc", ctx.home / ".zshrc", ctx.home / ".profile"):
|
||||
content = shell_file.read_text(encoding="utf-8")
|
||||
for literal in (
|
||||
'export COPILOT_PROVIDER_TYPE="openai"',
|
||||
'export COPILOT_PROVIDER_BASE_URL="http://127.0.0.1:9005/v1"',
|
||||
'export COPILOT_PROVIDER_WIRE_API="completions"',
|
||||
):
|
||||
if literal not in content:
|
||||
raise AssertionError(f"{shell_file.name} missing {literal!r}")
|
||||
|
||||
copilot_calls = [
|
||||
record["argv"] for record in _read_jsonl(ctx.shim_log) if record["tool"] == "copilot"
|
||||
]
|
||||
expected = [
|
||||
["plugin", "marketplace", "add", str(REPO_ROOT_IN_CONTAINER)],
|
||||
["plugin", "install", "headroom@headroom-marketplace"],
|
||||
]
|
||||
if copilot_calls != expected:
|
||||
raise AssertionError(f"Unexpected Copilot install commands: {copilot_calls}")
|
||||
|
||||
|
||||
def _verify_codex_local(ctx: CaseContext) -> None:
|
||||
config = (ctx.project / ".codex" / "config.toml").read_text(encoding="utf-8")
|
||||
hooks = json.loads((ctx.project / ".codex" / "hooks.json").read_text(encoding="utf-8"))
|
||||
profile = init_cli._local_profile(ctx.project)
|
||||
|
||||
if 'base_url = "http://127.0.0.1:9012/v1"' not in config:
|
||||
raise AssertionError("Codex config should point at the requested proxy port (9012)")
|
||||
if config.count("[features]") != 1:
|
||||
raise AssertionError("Codex config should keep a single [features] table")
|
||||
if "codex_hooks = true" not in config:
|
||||
raise AssertionError("Codex config should enable codex_hooks")
|
||||
command = hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
_expect_hook_command(command, profile)
|
||||
|
||||
manifest = _read_manifest(ctx.home, profile)
|
||||
targets = manifest["targets"]
|
||||
if set(targets) != {"claude", "codex"}:
|
||||
raise AssertionError(f"Unexpected merged targets: {targets}")
|
||||
|
||||
|
||||
# ----- new cases (issue #245 fix + per-subcommand coverage) -------------------
|
||||
|
||||
|
||||
def _verify_claude_global(ctx: CaseContext) -> None:
|
||||
settings = json.loads((ctx.home / ".claude" / "settings.json").read_text(encoding="utf-8"))
|
||||
if settings["env"]["ANTHROPIC_BASE_URL"] != "http://127.0.0.1:8787":
|
||||
raise AssertionError(
|
||||
f"Claude user settings should default to port 8787, got "
|
||||
f"{settings['env']['ANTHROPIC_BASE_URL']!r}"
|
||||
)
|
||||
_expect_hook_command(
|
||||
settings["hooks"]["SessionStart"][0]["hooks"][0]["command"],
|
||||
init_cli._GLOBAL_PROFILE,
|
||||
)
|
||||
|
||||
|
||||
def _verify_codex_global(ctx: CaseContext) -> None:
|
||||
config = (ctx.home / ".codex" / "config.toml").read_text(encoding="utf-8")
|
||||
if 'base_url = "http://127.0.0.1:8787/v1"' not in config:
|
||||
raise AssertionError("Codex user config should point at port 8787 by default")
|
||||
if "codex_hooks = true" not in config:
|
||||
raise AssertionError("Codex user config should enable codex_hooks")
|
||||
hooks = json.loads((ctx.home / ".codex" / "hooks.json").read_text(encoding="utf-8"))
|
||||
_expect_hook_command(
|
||||
hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"],
|
||||
init_cli._GLOBAL_PROFILE,
|
||||
)
|
||||
|
||||
|
||||
# ----- case tables ------------------------------------------------------------
|
||||
|
||||
|
||||
def existing_sequence_cases() -> list[Case]:
|
||||
"""Preserves the original run.py scenario in one shared scratch."""
|
||||
|
||||
return [
|
||||
Case(
|
||||
name="seq_claude_local",
|
||||
argv=["init", "--port", "9011", "claude"],
|
||||
shims={"claude": "record-args", "copilot": "record-args"},
|
||||
expected_exit=0,
|
||||
expected_stdout_contains=["Configured Claude Code (local scope)"],
|
||||
extra_assertions=[_verify_claude_local],
|
||||
),
|
||||
Case(
|
||||
name="seq_copilot_global",
|
||||
argv=["init", "-g", "--port", "9005", "--backend", "openai", "copilot"],
|
||||
shims={}, # reuse shims from prior case in the sequence
|
||||
expected_exit=0,
|
||||
expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"],
|
||||
extra_assertions=[_verify_copilot_global],
|
||||
),
|
||||
Case(
|
||||
name="seq_codex_local",
|
||||
argv=["init", "--port", "9012", "codex"],
|
||||
shims={},
|
||||
expected_exit=0,
|
||||
expected_stdout_contains=["Configured Codex (local scope)"],
|
||||
extra_assertions=[_verify_codex_local],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def bare_init_g_cases() -> list[Case]:
|
||||
"""Bare ``headroom init -g`` — the direct coverage of issue #245."""
|
||||
|
||||
return [
|
||||
Case(
|
||||
name="bare_init_g_no_shims",
|
||||
argv=["init", "-g"],
|
||||
shims={}, # nothing on PATH
|
||||
expected_exit=1,
|
||||
expected_stderr_contains=[
|
||||
# every target should be listed so the user knows what was tried
|
||||
"claude",
|
||||
"codex",
|
||||
"copilot",
|
||||
"openclaw",
|
||||
# concrete escape hatch — exactly what the user should type next
|
||||
"headroom init -g claude",
|
||||
# confirm -g itself is still the right flag
|
||||
"-g",
|
||||
],
|
||||
),
|
||||
Case(
|
||||
name="bare_init_g_with_all_shims",
|
||||
argv=["init", "-g"],
|
||||
shims={
|
||||
"claude": "record-args",
|
||||
"codex": "noop",
|
||||
"copilot": "record-args",
|
||||
"openclaw": "noop",
|
||||
},
|
||||
expected_exit=0,
|
||||
expected_stdout_contains=[
|
||||
"Configured Claude Code (user scope)",
|
||||
"Configured GitHub Copilot CLI (user scope)",
|
||||
"Configured Codex (user scope)",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def per_subcommand_cases() -> list[Case]:
|
||||
"""One case per ``headroom init -g <agent>`` with only that agent's shim."""
|
||||
|
||||
return [
|
||||
Case(
|
||||
name="init_g_claude_explicit",
|
||||
argv=["init", "-g", "claude"],
|
||||
shims={"claude": "record-args"},
|
||||
expected_exit=0,
|
||||
expected_stdout_contains=["Configured Claude Code (user scope)"],
|
||||
expected_files=["{home}/.claude/settings.json"],
|
||||
extra_assertions=[_verify_claude_global],
|
||||
),
|
||||
Case(
|
||||
name="init_g_codex_explicit",
|
||||
argv=["init", "-g", "codex"],
|
||||
shims={"codex": "noop"},
|
||||
expected_exit=0,
|
||||
expected_stdout_contains=["Configured Codex (user scope)"],
|
||||
expected_files=[
|
||||
"{home}/.codex/config.toml",
|
||||
"{home}/.codex/hooks.json",
|
||||
],
|
||||
extra_assertions=[_verify_codex_global],
|
||||
),
|
||||
Case(
|
||||
name="init_g_copilot_explicit",
|
||||
argv=["init", "-g", "copilot"],
|
||||
shims={"copilot": "record-args"},
|
||||
expected_exit=0,
|
||||
expected_stdout_contains=["Configured GitHub Copilot CLI (user scope)"],
|
||||
expected_files=["{home}/.copilot/config.json"],
|
||||
),
|
||||
# openclaw delegates to `headroom wrap openclaw` which has its own
|
||||
# (more expensive) init path and isn't stubbable with a simple shim.
|
||||
# We assert it fails fast with a clear error when not installed, and
|
||||
# rely on the `bare_init_g_with_all_shims` case (which uses a noop
|
||||
# openclaw shim + claude/codex/copilot shims) to cover the success
|
||||
# path alongside the other agents.
|
||||
Case(
|
||||
name="init_g_openclaw_missing",
|
||||
argv=["init", "-g", "openclaw"],
|
||||
shims={},
|
||||
expected_exit=1,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def verbose_cases() -> list[Case]:
|
||||
"""Verbose flag smoke tests — debug lines should appear on stderr."""
|
||||
|
||||
return [
|
||||
Case(
|
||||
name="init_verbose_no_shims",
|
||||
argv=["init", "-v", "-g"],
|
||||
shims={},
|
||||
expected_exit=1,
|
||||
expected_stderr_contains=[
|
||||
# A few structural markers from the verbose log. Kept loose so
|
||||
# minor wording tweaks don't break the test.
|
||||
"detect_init_targets",
|
||||
"claude",
|
||||
"global_scope=True",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
rc = 0
|
||||
rc |= run_case_sequence(existing_sequence_cases(), label="existing-sequence")
|
||||
rc |= run_cases(bare_init_g_cases())
|
||||
rc |= run_cases(per_subcommand_cases())
|
||||
rc |= run_cases(verbose_cases())
|
||||
if rc != 0:
|
||||
raise SystemExit(rc)
|
||||
print("[e2e] init e2e completed successfully", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
|
|||
1496
headroom/cli/init.py
1496
headroom/cli/init.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "headroom",
|
||||
"version": "0.11.2",
|
||||
"version": "0.12.0",
|
||||
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
||||
"author": {
|
||||
"name": "Headroom Contributors",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "headroom",
|
||||
"version": "0.11.2",
|
||||
"version": "0.12.0",
|
||||
"description": "Headroom startup hooks for Claude Code and GitHub Copilot CLI.",
|
||||
"author": {
|
||||
"name": "Headroom Contributors",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue