Files
bot-bottle/tests/unit/test_backend_selection.py
T
didericis-claude adff1263d8
test / unit (pull_request) Successful in 29s
test / integration (pull_request) Successful in 41s
feat(cli): cross-backend list active + --backend flag + dashboard picker (issue #77)
CLI and dashboard now share one cross-backend abstraction for
listing + launching bottles, so adding a backend (docker /
smolmachines) lights up in both places without separate wiring.

Backend abstraction:
- New `ActiveBottle` dataclass (`backend_name`, `slug`,
  `agent_name`, `started_at`, `services`) replaces the
  docker-specific `ActiveAgent`. Same field surface for the
  existing dashboard consumers; `ActiveAgent` becomes a typed
  alias for source-compat.
- New `BottleBackend.enumerate_active() -> Sequence[ActiveBottle]`
  replaces the old `list_active() -> None` (which printed and
  returned nothing). Docker implements it via the existing
  compose query; smolmachines implements it via `smolvm machine
  ls --json` cross-referenced with each bundle container's
  `CLAUDE_BOTTLE_SIDECAR_DAEMONS` env (`backend/smolmachines/
  enumerate.py`).
- New `enumerate_active_bottles()` and `known_backend_names()`
  module-level helpers fold every backend into one call.
- `get_bottle_backend(name=None)` takes an optional explicit
  name (precedence: arg > $CLAUDE_BOTTLE_BACKEND > "docker").

CLI:
- `./cli.py list active` enumerates every backend, prints
  tab-separated `<backend>\t<slug>\t<agent>\t<services>`. The
  smolmachines bottle the user was looking for now shows up.
- `./cli.py start` grows `--backend=<docker|smolmachines>`
  (choices pulled live from `known_backend_names()`). Threaded
  through `prepare_with_preflight(backend_name=...)` so the
  resume path picks up the flag too.

Dashboard:
- Active agents pane lists both backends (the row formatter now
  prefixes `[docker]` / `[smolmachines]`).
- New-agent flow inserts a backend picker modal between agent
  pick and preflight (`_backend_picker_modal`). Short-circuits
  when only one backend is registered.
- `discover_active_agents()` collapses to
  `enumerate_active_bottles()`; `_parse_services_by_project` and
  `_query_services_by_project` move to
  `backend/docker/cleanup.py` where the docker enumerator owns
  them.

Tests: parser + enumerate-active tests relocated to
`test_docker_enumerate_active.py`. New
`test_backend_selection.py` covers `get_bottle_backend`,
`known_backend_names`, `enumerate_active_bottles`. New
`test_cli_start_backend_flag.py` covers `--backend`'s argparse
shape + the explicit-over-env precedence.

605 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 18:27:12 -04:00

94 lines
3.0 KiB
Python

"""Unit: backend selection + cross-backend enumeration (issue #77).
`get_bottle_backend(name)` resolves a backend by explicit name,
env var, or default. `enumerate_active_bottles()` walks every
registered backend and concatenates their `ActiveBottle`
listings — the CLI and dashboard both go through this so adding
a backend lights it up in both places."""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
from claude_bottle import backend as backend_mod
from claude_bottle.backend import (
ActiveBottle,
enumerate_active_bottles,
get_bottle_backend,
known_backend_names,
)
class TestGetBottleBackend(unittest.TestCase):
def test_explicit_name_wins_over_env(self):
with patch.dict(os.environ, {"CLAUDE_BOTTLE_BACKEND": "smolmachines"}):
b = get_bottle_backend("docker")
self.assertEqual("docker", b.name)
def test_env_var_fallback(self):
with patch.dict(os.environ, {"CLAUDE_BOTTLE_BACKEND": "smolmachines"}):
b = get_bottle_backend()
self.assertEqual("smolmachines", b.name)
def test_default_docker(self):
with patch.dict(os.environ, {}, clear=True):
b = get_bottle_backend()
self.assertEqual("docker", b.name)
def test_unknown_dies(self):
with patch.object(backend_mod, "die", side_effect=SystemExit("die")):
with self.assertRaises(SystemExit):
get_bottle_backend("nonexistent")
class TestKnownBackendNames(unittest.TestCase):
def test_returns_both_backends_sorted(self):
self.assertEqual(("docker", "smolmachines"), known_backend_names())
class TestEnumerateActiveBottles(unittest.TestCase):
"""Combines each backend's `enumerate_active`. Each backend's
implementation has its own tests (`test_docker_enumerate_active`,
`test_smolmachines_*`); this just asserts the aggregator stitches
them together."""
def test_concatenates_per_backend(self):
a = ActiveBottle(
backend_name="docker", slug="a-1", agent_name="impl",
started_at="", services=("pipelock",),
)
b = ActiveBottle(
backend_name="smolmachines", slug="b-2", agent_name="research",
started_at="", services=(),
)
class _FakeBackend:
def __init__(self, items):
self._items = items
def enumerate_active(self):
return self._items
with patch.object(
backend_mod, "_BACKENDS",
{"docker": _FakeBackend([a]), "smolmachines": _FakeBackend([b])},
):
self.assertEqual([a, b], enumerate_active_bottles())
def test_empty_when_no_backends_have_active(self):
class _FakeBackend:
def enumerate_active(self):
return []
with patch.object(
backend_mod, "_BACKENDS",
{"docker": _FakeBackend(), "smolmachines": _FakeBackend()},
):
self.assertEqual([], enumerate_active_bottles())
if __name__ == "__main__":
unittest.main()