Files
bench/tests/test_install_first_boot.py
T
istosandClaude Fable 5 e800cf1a91 Address PR review: symlink-safe removal, guard-gated stamp, hermetic tests
The first-boot guard is now its own predicate gating both the clean and
the local/state/ stamp, so a disarmed run (pre-existing local/.env, or
self-hosted) touches nothing at all — before, it still created the stamp
directory on any vendored run. Removal unlinks symlinks instead of
following them into rmtree. Tests filter BOARD_* out of the subprocess
environment, assert the stamp stays absent when .env disarms the guard,
and cover the symlink case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 07:22:33 +02:00

144 lines
6.2 KiB
Python

"""install.py's first-boot cleaning: a vendored clone's very first run
clears the distribution's own cards so a new host starts with a pristine
board, and no later run ever touches the host's own. Run with:
python3 -m unittest discover -s tests
install.py is exercised end-to-end as a subprocess against scratch host
layouts — the same entry point start.sh uses — so what is asserted is
what a real first boot does to disk.
"""
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
STAGES = ["backlog", "to-do", "in-progress", "review", "done"]
KEEP = {".gitkeep", "task-template.md"}
def make_host(root: Path) -> Path:
"""A host project with a freshly vendored .task-manager: the real
install.py and claude adapter, plus the distribution's shipped cards."""
host = root / "host"
(host / ".claude").mkdir(parents=True)
tm = host / ".task-manager"
tm.mkdir()
shutil.copy(REPO / "install.py", tm / "install.py")
shutil.copytree(REPO / "manager" / "core" / "adapters" / "claude",
tm / "manager" / "core" / "adapters" / "claude")
for stage in STAGES + ["archive"]:
d = tm / "tasks" / stage
d.mkdir(parents=True)
(d / ".gitkeep").touch()
(d / "00-shipped-card.md").write_text("# Shipped\n", encoding="utf-8")
(tm / "tasks" / "task-template.md").write_text("# Template\n", encoding="utf-8")
for extra in ["plans", "reference"]:
d = tm / extra
d.mkdir()
(d / ".gitkeep").touch()
(d / "shipped.md").write_text("shipped\n", encoding="utf-8")
(tm / "reference" / "shots").mkdir()
(tm / "reference" / "shots" / "board.png").write_bytes(b"png")
return tm
def run_install(tm: Path, *args: str) -> subprocess.CompletedProcess:
env = {k: v for k, v in os.environ.items() if not k.startswith("BOARD_")}
return subprocess.run(
[sys.executable, str(tm / "install.py"), *args],
capture_output=True, text=True, cwd=tm.parent, env=env)
def shipped_files(tm: Path) -> list[Path]:
"""Every file under the cleaned directories that first boot should
have removed — empty means the board is pristine."""
return [p
for top in [tm / "tasks", tm / "plans", tm / "reference"]
for p in top.rglob("*")
if p.is_file() and p.name not in KEEP]
class FirstBoot(unittest.TestCase):
def setUp(self):
self.scratch = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, self.scratch, True)
self.tm = make_host(self.scratch)
def test_first_run_clears_shipped_content_and_prints_each_removal(self):
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(shipped_files(self.tm), [])
for stage in STAGES + ["archive"]:
self.assertTrue((self.tm / "tasks" / stage / ".gitkeep").is_file())
self.assertTrue((self.tm / "tasks" / "task-template.md").is_file())
self.assertTrue((self.tm / "plans" / ".gitkeep").is_file())
self.assertTrue((self.tm / "reference" / ".gitkeep").is_file())
for line in ["tasks/backlog/00-shipped-card.md",
"tasks/archive/00-shipped-card.md",
"plans/shipped.md", "reference/shots"]:
self.assertIn(f"removed {line}", result.stdout)
self.assertTrue((self.tm / "manager" / "local" / "state").is_dir())
def test_second_run_removes_nothing_and_host_cards_survive(self):
run_install(self.tm)
card = self.tm / "tasks" / "backlog" / "20-host-card.md"
card.write_text("# The host's own\n", encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("removed", result.stdout)
self.assertTrue(card.is_file())
self.assertIn("ok", result.stdout)
def test_dry_run_lists_without_removing(self):
result = run_install(self.tm, "--dry-run")
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("would remove tasks/backlog/00-shipped-card.md",
result.stdout)
self.assertNotIn("removed ", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
def test_existing_env_file_disarms_the_guard(self):
local = self.tm / "manager" / "local"
local.mkdir(parents=True)
(local / ".env").write_text("BOARD_PORT=26071\n", encoding="utf-8")
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertNotIn("removed", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((local / "state").exists())
def test_symlinked_leftover_is_unlinked_not_followed(self):
"""A symlink among the leftovers is removed as a link — the
directory it points to survives untouched."""
outside = self.scratch / "outside"
outside.mkdir()
(outside / "precious.md").write_text("keep me\n", encoding="utf-8")
link = self.tm / "tasks" / "backlog" / "10-linked"
link.symlink_to(outside)
result = run_install(self.tm)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertFalse(link.is_symlink())
self.assertFalse(link.exists())
self.assertTrue((outside / "precious.md").is_file())
def test_self_hosted_repo_is_never_cleaned(self):
"""When the manager IS the repo (bench itself, or a dev clone of
it), tasks/ is that repo's history — even unwired, never touched."""
subprocess.run(["git", "init", "-q", str(self.tm)], check=True,
capture_output=True)
(self.tm / ".claude").mkdir()
result = run_install(self.tm)
self.assertNotIn("removed", result.stdout)
self.assertNotEqual(shipped_files(self.tm), [])
self.assertFalse((self.tm / "manager" / "local" / "state").exists())
if __name__ == "__main__":
unittest.main()