"""Unit: capability_apply helpers (PRD 0016 Phase 2). docker cp / exec / rm / network rm paths are covered by the integration test in Phase 4. Here we cover: - fetch_current_dockerfile fallback chain (per-bottle → repo) - apply_capability_change writes the per-bottle Dockerfile and returns the correct (before, after). - apply_capability_change rejects empty input. """ import tempfile import unittest from pathlib import Path from bot_bottle import supervise from bot_bottle import bottle_state from bot_bottle.backend.docker import capability_apply from bot_bottle.backend.docker.capability_apply import ( CapabilityApplyError, apply_capability_change, fetch_current_dockerfile, ) class _FakeHomeMixin: def _setup_fake_home(self): self._tmp = tempfile.TemporaryDirectory(prefix="cap-apply-test.") original = supervise.bot_bottle_root def fake_root() -> Path: return Path(self._tmp.name) / ".bot-bottle" supervise.bot_bottle_root = fake_root # type: ignore[assignment] self._restore = lambda: setattr(supervise, "bot_bottle_root", original) def _teardown_fake_home(self): self._restore() self._tmp.cleanup() class TestFetchCurrentDockerfile(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() def tearDown(self): self._teardown_fake_home() def test_returns_per_bottle_dockerfile_when_present(self): bottle_state.write_per_bottle_dockerfile("dev", "FROM rebuilt\n") self.assertEqual("FROM rebuilt\n", fetch_current_dockerfile("dev")) def test_falls_back_to_repo_dockerfile_when_no_override(self): # The repo's Dockerfile actually exists; the test just checks # we get its content (non-empty) when no per-bottle override # is set. content = fetch_current_dockerfile("dev-no-override") self.assertIn("FROM ", content) class TestApplyCapabilityChange(_FakeHomeMixin, unittest.TestCase): def setUp(self): self._setup_fake_home() # Stub out the docker-dependent helpers. The orchestrator's # job is to sequence write + snapshot + push + teardown; we # validate that sequence here, not the docker primitives. self._calls: list[str] = [] self._orig_snapshot = capability_apply.snapshot_transcript self._orig_push = capability_apply._push_working_tree self._orig_teardown = capability_apply._teardown_bottle def stub_snapshot(slug: object) -> None: # type: ignore self._calls.append(f"snapshot:{slug}") def stub_push(slug: object) -> None: # type: ignore self._calls.append(f"push:{slug}") def stub_teardown(slug: object) -> None: # type: ignore self._calls.append(f"teardown:{slug}") capability_apply.snapshot_transcript = stub_snapshot # type: ignore[assignment] capability_apply._push_working_tree = stub_push # type: ignore[assignment] capability_apply._teardown_bottle = stub_teardown # type: ignore[assignment] def tearDown(self): capability_apply.snapshot_transcript = self._orig_snapshot # type: ignore[assignment] capability_apply._push_working_tree = self._orig_push # type: ignore[assignment] capability_apply._teardown_bottle = self._orig_teardown # type: ignore[assignment] self._teardown_fake_home() def test_writes_per_bottle_dockerfile_and_returns_before_after(self): bottle_state.write_per_bottle_dockerfile("dev", "FROM old\n") before, after = apply_capability_change("dev", "FROM new\nRUN apk add ripgrep\n") self.assertEqual("FROM old\n", before) self.assertEqual("FROM new\nRUN apk add ripgrep\n", after) self.assertEqual( "FROM new\nRUN apk add ripgrep\n", bottle_state.per_bottle_dockerfile("dev"), ) def test_calls_snapshot_push_teardown_in_order(self): apply_capability_change("dev", "FROM new\n") # Snapshot + push must happen BEFORE write_per_bottle_dockerfile # (so they capture pre-rebuild state) and BEFORE teardown (so # the agent container still exists to docker exec / cp from). # Teardown must be last. self.assertEqual( ["snapshot:dev", "push:dev", "teardown:dev"], self._calls, ) def test_marks_preserved_before_teardown(self): # cli.py's session-end cleanup reads the marker after the # bottle is torn down. The marker must therefore be written # before teardown — otherwise the cleanup would see no # marker and rm the state dir we just populated. apply_capability_change("dev", "FROM new\n") self.assertTrue(bottle_state.is_preserved("dev")) def test_first_change_falls_back_to_repo_dockerfile_for_before(self): # No per-bottle override yet — before-diff comes from the # repo's Dockerfile. before, after = apply_capability_change("dev-fresh", "FROM new\n") self.assertIn("FROM ", before) self.assertEqual("FROM new\n", after) def test_empty_dockerfile_rejected(self): with self.assertRaises(CapabilityApplyError): apply_capability_change("dev", " \n\t\n") if __name__ == "__main__": unittest.main()