"""Unit: _launch_bottle StaleImageError handling. Exercises the while-True / try-except loop around backend.launch: - headless mode → die on stale - interactive mode, user declines → stop without launching - interactive mode, user confirms → retry with skip_stale=True """ from __future__ import annotations import contextlib import io import tempfile import unittest from pathlib import Path from types import SimpleNamespace from typing import Any, cast from unittest.mock import MagicMock, call, patch from bot_bottle.image_cache import StaleImageError from bot_bottle.log import Die def _fake_plan() -> Any: provision = SimpleNamespace(startup_args=()) return cast(Any, SimpleNamespace( agent_provision=provision, agent_provider_template="claude", slug="dev-abc", )) def _stale_cm() -> MagicMock: """Return a context-manager mock whose __enter__ raises StaleImageError.""" cm = MagicMock() cm.__enter__ = MagicMock(side_effect=StaleImageError("image is 5 day(s) old")) cm.__exit__ = MagicMock(return_value=False) return cm def _ok_cm(bottle: Any) -> MagicMock: """Return a context-manager mock that yields `bottle`.""" cm = MagicMock() cm.__enter__ = MagicMock(return_value=bottle) cm.__exit__ = MagicMock(return_value=False) return cm class TestLaunchBottleStaleHandling(unittest.TestCase): def setUp(self): self._tmp = tempfile.mkdtemp(prefix="cli-stale-test.") def _spec(self): from bot_bottle.backend import BottleSpec from bot_bottle.manifest import ManifestIndex idx = ManifestIndex.from_json_obj({ "bottles": {"dev": {}}, "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, }) return BottleSpec( manifest=idx, agent_name="demo", copy_cwd=False, user_cwd=self._tmp, identity="dev-abc", ) def _run_launch(self, **patch_kwargs) -> int: import bot_bottle.cli.start as start_mod spec = self._spec() with patch.object(start_mod, "prepare_with_preflight", return_value=(_fake_plan(), "dev-abc")), \ patch.object(start_mod, "settle_state"), \ patch.object(start_mod, "info"): return start_mod._launch_bottle( spec, dry_run=False, backend_name="docker", **patch_kwargs, ) def test_headless_stale_calls_die(self): """In headless mode (assume_yes=True), a StaleImageError must call die().""" import bot_bottle.cli.start as start_mod backend_mock = MagicMock() backend_mock.launch.return_value = _stale_cm() with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \ patch.object(start_mod, "die", side_effect=Die("stale")): with self.assertRaises(Die): self._run_launch(assume_yes=True) def test_interactive_user_declines_stops_loop(self): """Interactive user answering 'n' → loop exits without a second launch.""" import bot_bottle.cli.start as start_mod backend_mock = MagicMock() backend_mock.launch.return_value = _stale_cm() with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \ patch.object(start_mod, "read_tty_line", return_value="n"), \ patch("sys.stderr", new_callable=io.StringIO): rc = self._run_launch(assume_yes=False) self.assertEqual(0, rc) # launch was called once (stale), then the user declined → no second call. backend_mock.launch.assert_called_once() def test_interactive_user_confirms_retries_with_skip_stale(self): """Interactive user answering 'y' → second launch with skip_stale=True.""" import bot_bottle.cli.start as start_mod bottle_mock = MagicMock() bottle_mock.name = "dev-abc" stale_calls = [0] def launch_side_effect(plan, *, skip_stale=False): if not skip_stale: return _stale_cm() return _ok_cm(bottle_mock) backend_mock = MagicMock() backend_mock.launch.side_effect = launch_side_effect with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \ patch.object(start_mod, "read_tty_line", return_value="y"), \ patch.object(start_mod, "attach_agent", return_value=0), \ patch.object(start_mod, "capture_claude_session_state"), \ patch("sys.stderr", new_callable=io.StringIO): rc = self._run_launch(assume_yes=False) self.assertEqual(0, rc) # First call: skip_stale=False → stale. Second call: skip_stale=True → ok. self.assertEqual(2, backend_mock.launch.call_count) calls = backend_mock.launch.call_args_list self.assertFalse(calls[0].kwargs.get("skip_stale", False)) self.assertTrue(calls[1].kwargs.get("skip_stale", False)) def test_interactive_yes_uppercase_also_accepted(self): """'Y' or 'YES' should also be accepted as confirmation.""" import bot_bottle.cli.start as start_mod bottle_mock = MagicMock() bottle_mock.name = "dev-abc" def launch_side_effect(plan, *, skip_stale=False): if not skip_stale: return _stale_cm() return _ok_cm(bottle_mock) backend_mock = MagicMock() backend_mock.launch.side_effect = launch_side_effect with patch.object(start_mod, "get_bottle_backend", return_value=backend_mock), \ patch.object(start_mod, "read_tty_line", return_value="YES"), \ patch.object(start_mod, "attach_agent", return_value=0), \ patch.object(start_mod, "capture_claude_session_state"), \ patch("sys.stderr", new_callable=io.StringIO): rc = self._run_launch(assume_yes=False) self.assertEqual(0, rc) self.assertEqual(2, backend_mock.launch.call_count) if __name__ == "__main__": unittest.main()