Files
webterm/tests/test_docker_watcher.py
T
GitHub Copilot 216380405a feat: add Docker watch mode for dynamic container sessions
- Add --docker-watch CLI flag to watch for containers with webterm-command label
- Containers with label 'auto' get bash exec, otherwise use label as command
- Dynamic dashboard updates via SSE when containers start/stop
- Add /tiles endpoint for JSON tile list
- Multi-stage Dockerfile for minimal production image
- Update README with docker-watch documentation

The docker watcher monitors Docker events and automatically:
- Adds terminal tiles when labeled containers start
- Removes tiles when containers stop
- Notifies dashboard via SSE for live updates
2026-01-28 12:45:02 +00:00

221 lines
7.6 KiB
Python

"""Tests for docker_watcher module."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from textual_webterm.docker_watcher import DEFAULT_COMMAND, LABEL_NAME, DockerWatcher
class TestDockerWatcher:
"""Tests for DockerWatcher class."""
def test_container_to_slug(self):
"""Test slug generation from container names."""
watcher = DockerWatcher(MagicMock())
# Test basic name
container = {"Names": ["/my-container"]}
assert watcher._container_to_slug(container) == "my-container"
# Test with underscores
container = {"Names": ["/my_container_name"]}
assert watcher._container_to_slug(container) == "my-container-name"
# Test with dots
container = {"Names": ["/service.name"]}
assert watcher._container_to_slug(container) == "service-name"
# Test fallback to ID
container = {"Id": "abc123def456"}
assert watcher._container_to_slug(container) == "abc123def456"
def test_get_container_name(self):
"""Test extracting container name."""
watcher = DockerWatcher(MagicMock())
container = {"Names": ["/my-container"]}
assert watcher._get_container_name(container) == "my-container"
container = {"Names": []}
container["Id"] = "abc123def456789"
assert watcher._get_container_name(container) == "abc123def456"
def test_get_container_command_auto(self):
"""Test command generation when label is 'auto'."""
watcher = DockerWatcher(MagicMock())
container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}}
expected = f"docker exec -it my-container {DEFAULT_COMMAND}"
assert watcher._get_container_command(container) == expected
def test_get_container_command_custom(self):
"""Test command when label has custom value."""
watcher = DockerWatcher(MagicMock())
container = {
"Names": ["/my-container"],
"Labels": {LABEL_NAME: "docker logs -f my-container"},
}
assert watcher._get_container_command(container) == "docker logs -f my-container"
@pytest.mark.asyncio
async def test_add_container(self):
"""Test adding a container."""
session_manager = MagicMock()
on_added = MagicMock()
watcher = DockerWatcher(session_manager, on_container_added=on_added)
container = {"Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto"}}
await watcher._add_container(container)
# Should add to session manager
session_manager.add_app.assert_called_once()
call_args = session_manager.add_app.call_args
assert call_args[0][0] == "test-container" # name
assert "docker exec -it test-container" in call_args[0][1] # command
assert call_args[0][2] == "test-container" # slug
assert call_args[1]["terminal"] is True
# Should call callback
on_added.assert_called_once_with("test-container", "test-container", call_args[0][1])
# Should track container
assert "test-container" in watcher._managed_containers
@pytest.mark.asyncio
async def test_add_container_already_managed(self):
"""Test adding a container that's already managed."""
session_manager = MagicMock()
watcher = DockerWatcher(session_manager)
watcher._managed_containers["test-container"] = "abc123"
container = {"Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto"}}
await watcher._add_container(container)
# Should not add again
session_manager.add_app.assert_not_called()
@pytest.mark.asyncio
async def test_remove_container(self):
"""Test removing a container."""
session_manager = MagicMock()
session_manager.apps_by_slug = {"test-container": MagicMock()}
session_manager.apps = [session_manager.apps_by_slug["test-container"]]
session_manager.get_session_by_route_key.return_value = None
on_removed = MagicMock()
watcher = DockerWatcher(session_manager, on_container_removed=on_removed)
watcher._managed_containers["test-container"] = "abc123"
await watcher._remove_container("abc123")
# Should remove from tracking
assert "test-container" not in watcher._managed_containers
# Should call callback
on_removed.assert_called_once_with("test-container")
@pytest.mark.asyncio
async def test_remove_container_not_managed(self):
"""Test removing a container that's not managed."""
session_manager = MagicMock()
on_removed = MagicMock()
watcher = DockerWatcher(session_manager, on_container_removed=on_removed)
await watcher._remove_container("unknown123")
# Should not call callback
on_removed.assert_not_called()
@pytest.mark.asyncio
async def test_start_stop(self):
"""Test starting and stopping the watcher."""
session_manager = MagicMock()
watcher = DockerWatcher(session_manager, socket_path="/nonexistent.sock")
# Mock the methods that would fail without Docker
watcher._get_labeled_containers = AsyncMock(return_value=[])
watcher._watch_events = AsyncMock()
await watcher.start()
assert watcher._running is True
await watcher.stop()
assert watcher._running is False
class TestDockerWatcherIntegration:
"""Integration-style tests for Docker watcher."""
@pytest.mark.asyncio
async def test_handle_start_event(self):
"""Test handling a container start event."""
session_manager = MagicMock()
watcher = DockerWatcher(session_manager)
# Mock the docker request to return container info
async def mock_request(method, path):
if "/containers/" in path and "/json" in path:
return (
200,
'{"Name": "/test-service", "Config": {"Labels": {"webterm-command": "auto"}}}',
)
return 404, ""
watcher._docker_request = mock_request
event = {
"Action": "start",
"Actor": {"ID": "container123", "Attributes": {LABEL_NAME: "auto"}},
}
await watcher._handle_event(event)
# Should add container
session_manager.add_app.assert_called_once()
@pytest.mark.asyncio
async def test_handle_die_event(self):
"""Test handling a container die event."""
session_manager = MagicMock()
session_manager.apps_by_slug = {}
session_manager.apps = []
session_manager.get_session_by_route_key.return_value = None
watcher = DockerWatcher(session_manager)
watcher._managed_containers["test-service"] = "container123"
event = {
"Action": "die",
"Actor": {"ID": "container123", "Attributes": {LABEL_NAME: "auto"}},
}
await watcher._handle_event(event)
# Should remove container
assert "test-service" not in watcher._managed_containers
@pytest.mark.asyncio
async def test_handle_event_without_label(self):
"""Test that events without our label are ignored."""
session_manager = MagicMock()
watcher = DockerWatcher(session_manager)
event = {
"Action": "start",
"Actor": {
"ID": "container123",
"Attributes": {}, # No label
},
}
await watcher._handle_event(event)
# Should not add container
session_manager.add_app.assert_not_called()