Files
bot-bottle/tests/unit/test_cli_start_stale.py
T
didericis-claude 60231d9070
lint / lint (push) Successful in 2m15s
test / unit (pull_request) Successful in 1m3s
test / integration (pull_request) Successful in 21s
test / coverage (pull_request) Successful in 1m15s
fix(tests): resolve pyright errors in stale-check test files
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.
2026-07-09 19:17:43 +00:00

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()