Refactor tests #6

Merged
didericis merged 9 commits from refactor-tests into main 2026-05-11 19:26:28 -04:00
4 changed files with 119 additions and 18 deletions
Showing only changes of commit beb0c9d58f - Show all commits
+7
View File
@@ -66,6 +66,13 @@ class BottlePlan(ABC):
def print(self, *, remote_control: bool) -> None: def print(self, *, remote_control: bool) -> None:
"""Render the y/N preflight summary to stderr.""" """Render the y/N preflight summary to stderr."""
@abstractmethod
def to_dict(self, *, remote_control: bool) -> dict[str, object]:
"""Return the plan as a JSON-serializable dict for machine
consumption (used by `start --dry-run --format=json`). The key
set is part of the CLI's user-facing contract — adding fields
is fine, renaming or removing is a breaking change."""
@dataclass(frozen=True) @dataclass(frozen=True)
class BottleCleanupPlan(ABC): class BottleCleanupPlan(ABC):
+34 -1
View File
@@ -12,7 +12,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from ...log import info from ...log import info
from ...pipelock import PipelockProxyPlan from ...pipelock import PipelockProxyPlan, pipelock_effective_allowlist
from .. import BottlePlan from .. import BottlePlan
@@ -75,3 +75,36 @@ class DockerBottlePlan(BottlePlan):
) )
info("remote-control : " + ("enabled" if remote_control else "disabled")) info("remote-control : " + ("enabled" if remote_control else "disabled"))
print(file=sys.stderr) print(file=sys.stderr)
def to_dict(self, *, remote_control: bool) -> dict[str, object]:
spec = self.spec
manifest = spec.manifest
agent = manifest.agents[spec.agent_name]
bottle = manifest.bottle_for(spec.agent_name)
env_names = list(bottle.env.keys())
if spec.forward_oauth_token:
env_names.append("CLAUDE_CODE_OAUTH_TOKEN")
hosts = pipelock_effective_allowlist(bottle)
return {
"agent": spec.agent_name,
"bottle": agent.bottle,
"container_name": self.container_name,
"image": self.image,
"derived_image": self.derived_image,
"stage_dir": str(self.stage_dir),
"runtime": "runsc" if self.use_runsc else "runc",
"env_names": env_names,
"skills": list(agent.skills),
"ssh_hosts": [e.Host for e in bottle.ssh],
"egress": {
"host_count": len(hosts),
"hosts": hosts,
},
"prompt": {
"length": len(agent.prompt),
"first_line": agent.prompt.splitlines()[0] if agent.prompt else "",
},
"remote_control": remote_control,
}
+16 -1
View File
@@ -5,6 +5,7 @@ session ends."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import os import os
import shutil import shutil
import sys import sys
@@ -12,7 +13,7 @@ import tempfile
from pathlib import Path from pathlib import Path
from ..backend import BottleSpec, get_bottle_backend from ..backend import BottleSpec, get_bottle_backend
from ..log import info from ..log import die, info
from ..manifest import Manifest from ..manifest import Manifest
from ._common import PROG, USER_CWD, read_tty_line from ._common import PROG, USER_CWD, read_tty_line
@@ -22,10 +23,18 @@ def cmd_start(argv: list[str]) -> int:
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image") parser.add_argument("--cwd", action="store_true", help="copy host cwd into a derived image")
parser.add_argument("--remote-control", action="store_true") parser.add_argument("--remote-control", action="store_true")
parser.add_argument(
"--format",
choices=("text", "json"),
default="text",
help="preflight output format; --format=json requires --dry-run",
)
parser.add_argument("name", help="agent name defined in claude-bottle.json") parser.add_argument("name", help="agent name defined in claude-bottle.json")
args = parser.parse_args(argv) args = parser.parse_args(argv)
dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1" dry_run = args.dry_run or os.environ.get("CLAUDE_BOTTLE_DRY_RUN") == "1"
if args.format == "json" and not dry_run:
die("--format=json requires --dry-run")
manifest = Manifest.resolve(USER_CWD) manifest = Manifest.resolve(USER_CWD)
spec = BottleSpec( spec = BottleSpec(
@@ -40,6 +49,12 @@ def cmd_start(argv: list[str]) -> int:
try: try:
backend = get_bottle_backend() backend = get_bottle_backend()
plan = backend.prepare(spec, stage_dir=stage_dir) plan = backend.prepare(spec, stage_dir=stage_dir)
if args.format == "json":
json.dump(plan.to_dict(remote_control=args.remote_control), sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
plan.print(remote_control=args.remote_control) plan.print(remote_control=args.remote_control)
if dry_run: if dry_run:
+62 -16
View File
@@ -1,10 +1,9 @@
"""Integration: cli.py start --dry-run renders the planned shape and """Integration: cli.py start --dry-run --format=json renders a stable
does not create any docker resources. Confirms the preflight contract machine-readable plan and creates zero Docker resources. The shape of
from PRD 0001 (allowlist line in the plan, no docker side effects).""" the JSON document is part of the CLI's user-facing contract."""
import json import json
import os import os
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -13,12 +12,12 @@ from pathlib import Path
from tests._docker import skip_unless_docker from tests._docker import skip_unless_docker
REPO_ROOT = Path(__file__).resolve().parent.parent REPO_ROOT = Path(__file__).resolve().parent.parent.parent
@skip_unless_docker() @skip_unless_docker()
class TestDryRunPlan(unittest.TestCase): class TestDryRunPlan(unittest.TestCase):
def test_dry_run(self): def test_dry_run_emits_structured_plan(self):
work_dir = Path(tempfile.mkdtemp()) work_dir = Path(tempfile.mkdtemp())
try: try:
manifest = work_dir / "claude-bottle.json" manifest = work_dir / "claude-bottle.json"
@@ -34,24 +33,43 @@ class TestDryRunPlan(unittest.TestCase):
env = os.environ.copy() env = os.environ.copy()
env["HOME"] = str(work_dir) env["HOME"] = str(work_dir)
env["CLAUDE_BOTTLE_DRY_RUN"] = "1" env.pop("CLAUDE_BOTTLE_DRY_RUN", None)
result = subprocess.run( result = subprocess.run(
[sys.executable, str(REPO_ROOT / "cli.py"), "start", "demo"], [
sys.executable, str(REPO_ROOT / "cli.py"),
"start", "--dry-run", "--format", "json", "demo",
],
cwd=work_dir, cwd=work_dir,
env=env, env=env,
capture_output=True, capture_output=True,
text=True, text=True,
) )
out = result.stdout + result.stderr self.assertEqual(
0, result.returncode,
f"start --dry-run failed: stderr={result.stderr}",
)
self.assertIn("egress", out, "preflight: egress line present") plan = json.loads(result.stdout)
# 7 baked defaults + 1 bottle entry = 8.
self.assertRegex(out, r"8 hosts allowed", "preflight: bottle entry counted")
self.assertIn("api.anthropic.com", out, "preflight: baked default shown")
self.assertRegex(out, r"runtime\s*:\s*runc", "preflight: default runtime shown")
self.assertIn("dry-run requested", out, "dry-run banner present")
self.assertNotIn("/dev/tty", out, "dry-run exited before tty prompt")
self.assertEqual("demo", plan["agent"])
self.assertEqual("dev", plan["bottle"])
self.assertEqual("runc", plan["runtime"],
"runsc isn't available on the CI runner")
self.assertEqual([], plan["skills"])
self.assertEqual([], plan["ssh_hosts"])
self.assertEqual(False, plan["remote_control"])
self.assertEqual(0, plan["prompt"]["length"])
# User-declared host + a baked default both present; the union
# is sorted and deduplicated.
hosts = plan["egress"]["hosts"]
self.assertIn("example.org", hosts)
self.assertIn("api.anthropic.com", hosts)
self.assertEqual(plan["egress"]["host_count"], len(hosts))
self.assertEqual(sorted(set(hosts)), hosts,
"hosts must be sorted and deduplicated")
# No Docker side effects.
self.assertEqual(nets_before, self._count_claude_bottle_networks(), self.assertEqual(nets_before, self._count_claude_bottle_networks(),
"no networks created") "no networks created")
self.assertEqual(ctrs_before, self._count_claude_bottle_containers(), self.assertEqual(ctrs_before, self._count_claude_bottle_containers(),
@@ -60,6 +78,34 @@ class TestDryRunPlan(unittest.TestCase):
import shutil import shutil
shutil.rmtree(work_dir, ignore_errors=True) shutil.rmtree(work_dir, ignore_errors=True)
def test_json_format_requires_dry_run(self):
"""The CLI rejects --format=json without --dry-run; emitting JSON
in a real run would race the y/N prompt."""
work_dir = Path(tempfile.mkdtemp())
try:
manifest = work_dir / "claude-bottle.json"
manifest.write_text(json.dumps({
"bottles": {"dev": {}},
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
}))
env = os.environ.copy()
env["HOME"] = str(work_dir)
env.pop("CLAUDE_BOTTLE_DRY_RUN", None)
result = subprocess.run(
[
sys.executable, str(REPO_ROOT / "cli.py"),
"start", "--format", "json", "demo",
],
cwd=work_dir,
env=env,
capture_output=True,
text=True,
)
self.assertNotEqual(0, result.returncode)
finally:
import shutil
shutil.rmtree(work_dir, ignore_errors=True)
def _count_claude_bottle_networks(self) -> int: def _count_claude_bottle_networks(self) -> int:
result = subprocess.run( result = subprocess.run(
["docker", "network", "ls", "--format", "{{.Name}}"], ["docker", "network", "ls", "--format", "{{.Name}}"],