"""Tests for docker_watcher module.""" from __future__ import annotations from unittest.mock import AsyncMock, MagicMock import pytest from webterm.docker_watcher import ( AUTO_COMMAND_SENTINEL, LABEL_NAME, THEME_LABEL, DockerWatcher, _has_webterm_label, ) @pytest.fixture def session_manager(): manager = MagicMock() manager.apps_by_slug = {} manager.apps = [] manager.get_session_by_route_key.return_value = None return manager @pytest.fixture def docker_watcher(session_manager): return DockerWatcher(session_manager) class TestDockerWatcher: """Tests for DockerWatcher class.""" def test_container_to_slug(self, docker_watcher): """Test slug generation from container names.""" # Test basic name container = {"Names": ["/my-container"]} assert docker_watcher._container_to_slug(container) == "my-container" # Test with underscores container = {"Names": ["/my_container_name"]} assert docker_watcher._container_to_slug(container) == "my-container-name" # Test with dots container = {"Names": ["/service.name"]} assert docker_watcher._container_to_slug(container) == "service-name" # Test fallback to ID container = {"Id": "abc123def456"} assert docker_watcher._container_to_slug(container) == "abc123def456" def test_get_container_name(self, docker_watcher): """Test extracting container name.""" container = {"Names": ["/my-container"]} assert docker_watcher._get_container_name(container) == "my-container" container = {"Names": []} container["Id"] = "abc123def456789" assert docker_watcher._get_container_name(container) == "abc123def456" def test_get_container_command_auto(self, docker_watcher): """Test command generation when label is 'auto'.""" container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}} assert docker_watcher._get_container_command(container) == AUTO_COMMAND_SENTINEL def test_get_container_command_custom(self, docker_watcher): """Test command when label has custom value.""" container = { "Names": ["/my-container"], "Labels": {LABEL_NAME: "docker logs -f my-container"}, } assert docker_watcher._get_container_command(container) == "docker logs -f my-container" def test_get_container_theme(self, docker_watcher): container = {"Labels": {THEME_LABEL: "nord"}} assert docker_watcher._get_container_theme(container) == "nord" def test_get_container_theme_blank(self, docker_watcher): container = {"Labels": {THEME_LABEL: " "}} assert docker_watcher._get_container_theme(container) is None @pytest.mark.asyncio async def test_add_container(self, session_manager): """Test adding a container.""" on_added = MagicMock() watcher = DockerWatcher(session_manager, on_container_added=on_added) container = { "Id": "abc123", "Names": ["/test-container"], "Labels": {LABEL_NAME: "auto", THEME_LABEL: "monokai"}, } 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 call_args[0][1] == AUTO_COMMAND_SENTINEL # command assert call_args[0][2] == "test-container" # slug assert call_args[1]["terminal"] is True assert call_args[1]["theme"] == "monokai" # 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, session_manager): """Test adding a container that's already managed.""" 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, session_manager): """Test removing a container.""" 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_with_active_session(self, session_manager): """Test removing a container that has an active session cleans up session.""" mock_session = MagicMock() mock_app = MagicMock() session_manager.apps_by_slug = {"test-container": mock_app} session_manager.apps = [mock_app] session_manager.get_session_by_route_key.return_value = mock_session session_manager.routes = MagicMock() session_manager.routes.get.return_value = "session-123" session_manager.close_session = AsyncMock() on_removed = MagicMock() watcher = DockerWatcher(session_manager, on_container_removed=on_removed) watcher._managed_containers["test-container"] = "abc123" await watcher._remove_container("abc123") # Session should be closed session_manager.close_session.assert_called_once_with("session-123") # App should be removed after session cleanup assert "test-container" not in session_manager.apps_by_slug assert mock_app not in session_manager.apps # Container should be untracked assert "test-container" not in watcher._managed_containers on_removed.assert_called_once_with("test-container") """Test removing a container that's not managed.""" 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, session_manager): """Test starting and stopping the watcher.""" 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, session_manager): """Test handling a container start event.""" 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, session_manager): """Test handling a container die event.""" 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, session_manager): """Test that events without our label are ignored.""" watcher = DockerWatcher(session_manager) watcher._docker_request = AsyncMock(return_value=(404, "")) 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() @pytest.mark.asyncio async def test_handle_start_event_label_added_after_start(self, session_manager): """Container that gains label after start is picked up.""" watcher = DockerWatcher(session_manager) 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": {}, # Labels not present on event }, } await watcher._handle_event(event) session_manager.add_app.assert_called_once() @pytest.mark.asyncio async def test_handle_start_event_null_labels(self, session_manager): """Container with null labels in Docker inspect response is handled gracefully.""" watcher = DockerWatcher(session_manager) async def mock_request(method, path): if "/containers/" in path and "/json" in path: # Docker returns null for Labels when container has none return ( 200, '{"Name": "/no-labels-container", "Config": {"Labels": null}}', ) return 404, "" watcher._docker_request = mock_request event = { "Action": "start", "Actor": { "ID": "container456", "Attributes": {}, }, } # Should not raise an exception await watcher._handle_event(event) # Should not add container (no webterm labels) session_manager.add_app.assert_not_called() @pytest.mark.parametrize( ("labels", "expected"), [ ({"webterm-command": "echo hi"}, "echo hi"), ({"webterm-command": "auto"}, AUTO_COMMAND_SENTINEL), ({"webterm-command": ""}, AUTO_COMMAND_SENTINEL), ({"other": "value"}, AUTO_COMMAND_SENTINEL), ], ) def test_get_container_command_variants(docker_watcher, labels, expected): container = {"Names": ["/my-container"], "Labels": labels} assert docker_watcher._get_container_command(container) == expected def test_auto_command_env_override(monkeypatch, docker_watcher): monkeypatch.setenv("WEBTERM_DOCKER_AUTO_COMMAND", "/bin/sh") container = {"Names": ["/my-container"], "Labels": {LABEL_NAME: "auto"}} assert docker_watcher._get_container_command(container) == AUTO_COMMAND_SENTINEL @pytest.mark.asyncio @pytest.mark.parametrize( ("status", "body", "expected"), [ (200, '[{"Id":"abc","Names":["/c1"],"Labels":{"webterm-command":"auto"}}]', 1), (200, "[]", 0), (500, "error", 0), ], ) async def test_get_labeled_containers_handles_status( docker_watcher, status, body, expected, monkeypatch ): async def fake_request(method: str, path: str): return status, body monkeypatch.setattr(docker_watcher, "_docker_request", fake_request) result = await docker_watcher._get_labeled_containers() assert len(result) == expected @pytest.mark.asyncio async def test_watch_events_recovers_from_errors(docker_watcher, monkeypatch): docker_watcher._running = True async def fail_once(*_args, **_kwargs): docker_watcher._running = False raise OSError("boom") async def fake_sleep(_seconds): return None monkeypatch.setattr("webterm.docker_watcher.asyncio.open_unix_connection", fail_once) monkeypatch.setattr("webterm.docker_watcher.asyncio.sleep", fake_sleep) await docker_watcher._watch_events() class TestHasWebtermLabel: """Tests for _has_webterm_label helper.""" def test_has_command_label(self): """Container with webterm-command label is detected.""" assert _has_webterm_label({LABEL_NAME: "auto"}) is True def test_has_theme_label(self): """Container with webterm-theme label is detected.""" assert _has_webterm_label({THEME_LABEL: "dracula"}) is True def test_has_both_labels(self): """Container with both labels is detected.""" assert _has_webterm_label({LABEL_NAME: "bash", THEME_LABEL: "dark"}) is True def test_no_webterm_labels(self): """Container without webterm labels is not detected.""" assert _has_webterm_label({"other-label": "value"}) is False def test_empty_attributes(self): """Empty attributes means no labels.""" assert _has_webterm_label({}) is False class TestHandleEventWithThemeLabel: """Tests for event handling with theme-only labels.""" @pytest.mark.asyncio async def test_handle_start_event_theme_only(self): """Container with only theme label is picked up.""" manager = MagicMock() manager.apps_by_slug = {} manager.apps = [] watcher = DockerWatcher(manager) async def mock_request(method, path): if "/containers/" in path and "/json" in path: import json return 200, json.dumps( { "Id": "abc123", "Name": "/themed-container", "Config": {"Labels": {THEME_LABEL: "monokai"}}, } ) return 404, "" watcher._docker_request = mock_request event = { "Action": "start", "Actor": { "ID": "abc123", "Attributes": {THEME_LABEL: "monokai"}, # Only theme label }, } await watcher._handle_event(event) # Should add container with auto command manager.add_app.assert_called_once() call_args = manager.add_app.call_args assert call_args[0][1] == AUTO_COMMAND_SENTINEL # command arg assert call_args[1]["theme"] == "monokai"