|
6 | 6 | import sys |
7 | 7 | import tempfile |
8 | 8 | import shutil |
| 9 | +from pathlib import Path |
9 | 10 | import pytest |
10 | 11 |
|
11 | 12 | # Add project root to path |
@@ -292,3 +293,79 @@ def _validate(skill_path: str) -> dict: |
292 | 293 | return result |
293 | 294 |
|
294 | 295 | return _validate |
| 296 | + |
| 297 | + |
| 298 | +# ============================================================ |
| 299 | +# CI-safe isolation fixtures (PR #28: make tests/ fail-loud-able) |
| 300 | +# ============================================================ |
| 301 | + |
| 302 | +@pytest.fixture |
| 303 | +def isolated_home(tmp_path, monkeypatch): |
| 304 | + """Isolate $HOME to a temp dir seeded with the minimal Ralph layout. |
| 305 | +
|
| 306 | + Redirects BOTH ``$HOME`` (for shell hooks that read ``${HOME}``) and |
| 307 | + ``Path.home()`` (for Python code), so a test never touches the developer's |
| 308 | + real ~/.claude or ~/.ralph. The repo's hooks are symlinked in so they |
| 309 | + resolve to the real, versioned scripts under the temp HOME. |
| 310 | + """ |
| 311 | + import json |
| 312 | + |
| 313 | + home = tmp_path / "home" |
| 314 | + for sub in ( |
| 315 | + ".claude/state", ".claude/teams", ".claude/tasks", ".claude/skills", ".claude/scripts", |
| 316 | + ".ralph/logs", ".ralph/state", ".ralph/handoffs", ".ralph/ledgers", |
| 317 | + ".ralph/config", ".ralph/temp", ".ralph/markers", |
| 318 | + ): |
| 319 | + (home / sub).mkdir(parents=True, exist_ok=True) |
| 320 | + |
| 321 | + # Point ~/.claude/hooks at the repo's real hooks (no duplication, no dev HOME). |
| 322 | + repo_hooks = Path(PROJECT_ROOT) / ".claude" / "hooks" |
| 323 | + hooks_link = home / ".claude" / "hooks" |
| 324 | + if repo_hooks.is_dir() and not hooks_link.exists(): |
| 325 | + hooks_link.symlink_to(repo_hooks) |
| 326 | + |
| 327 | + # Minimal valid config so settings/feature readers find a non-empty file. |
| 328 | + (home / ".claude" / "settings.json").write_text( |
| 329 | + json.dumps({"hooks": {}}), encoding="utf-8" |
| 330 | + ) |
| 331 | + (home / ".ralph" / "config" / "features.json").write_text( |
| 332 | + json.dumps({"RALPH_ENABLE_HANDOFF": True}), encoding="utf-8" |
| 333 | + ) |
| 334 | + |
| 335 | + monkeypatch.setenv("HOME", str(home)) |
| 336 | + monkeypatch.setattr(Path, "home", lambda: home) |
| 337 | + return home |
| 338 | + |
| 339 | + |
| 340 | +@pytest.fixture |
| 341 | +def requires_tool(): |
| 342 | + """Return a helper that skips the test when a named CLI tool is absent. |
| 343 | +
|
| 344 | + Usage: ``def test_x(requires_tool): requires_tool("rsync")``. |
| 345 | + """ |
| 346 | + def _require(name): |
| 347 | + if shutil.which(name) is None: |
| 348 | + pytest.skip(f"required tool not available on PATH: {name}") |
| 349 | + return _require |
| 350 | + |
| 351 | + |
| 352 | +@pytest.fixture |
| 353 | +def git_repo(tmp_path): |
| 354 | + """Create a throwaway git repo for hook/worktree tests (skips if git absent).""" |
| 355 | + import subprocess |
| 356 | + |
| 357 | + if shutil.which("git") is None: |
| 358 | + pytest.skip("git not available on PATH") |
| 359 | + |
| 360 | + repo = tmp_path / "repo" |
| 361 | + repo.mkdir() |
| 362 | + env = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull} |
| 363 | + run = lambda *a: subprocess.run(a, cwd=repo, check=True, env=env, |
| 364 | + capture_output=True, text=True) |
| 365 | + run("git", "init", "-q") |
| 366 | + run("git", "config", "user.email", "test@example.com") |
| 367 | + run("git", "config", "user.name", "ralph-test") |
| 368 | + (repo / "README.md").write_text("test\n", encoding="utf-8") |
| 369 | + run("git", "add", "-A") |
| 370 | + run("git", "commit", "-qm", "init") |
| 371 | + return repo |
0 commit comments