test: add unit tests for stale-image checks to reach ≥90% diff-coverage
Covers StaleImageError / check_stale / check_stale_path, the BottleBackend.launch template method (skip_stale flag), stale_checks functions across docker / smolmachines / macos-container backends, _build_images cached paths in the macOS backend, image_created_at edge cases in both docker and container util modules, the CLI stale loop (headless die, interactive decline, interactive confirm + retry), and ConfigStore.cached_image_stale_warning_days fallback paths. Diff-coverage: 488/507 changed lines covered (96.3%).
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user