60231d9070
Remove unused imports, add missing type annotations, fix Die() constructor calls (int code, not str), replace fake backend subclass with patch.object approach to avoid reportMissingParameterType and override-incompatibility errors in strict pyright mode.
165 lines
6.0 KiB
Python
165 lines
6.0 KiB
Python
"""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 io
|
|
import tempfile
|
|
import unittest
|
|
from types import SimpleNamespace
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock, 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) -> None:
|
|
self._tmp = tempfile.mkdtemp(prefix="cli-stale-test.")
|
|
|
|
def _spec(self) -> Any:
|
|
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: Any) -> 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) -> None:
|
|
"""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()):
|
|
with self.assertRaises(Die):
|
|
self._run_launch(assume_yes=True)
|
|
|
|
def test_interactive_user_declines_stops_loop(self) -> None:
|
|
"""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) -> None:
|
|
"""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"
|
|
|
|
def launch_side_effect(plan: Any, *, skip_stale: bool = False) -> Any:
|
|
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) -> None:
|
|
"""'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: Any, *, skip_stale: bool = False) -> Any:
|
|
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()
|