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
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""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()
|
||||
@@ -183,7 +183,9 @@ class TestLocalServerHelpers:
|
||||
screen_buffer = screen_buffer_factory(["hello", ""])
|
||||
mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||
|
||||
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: mock_session)
|
||||
monkeypatch.setattr(
|
||||
server.session_manager, "get_session_by_route_key", lambda _rk: mock_session
|
||||
)
|
||||
|
||||
response = await server._handle_screenshot(request)
|
||||
assert response.content_type == "image/svg+xml"
|
||||
@@ -311,7 +313,9 @@ class TestLocalServerHelpers:
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_get_ws_url_variants(self, server, mock_request, headers, secure, expected_parts, forbidden_parts):
|
||||
def test_get_ws_url_variants(
|
||||
self, server, mock_request, headers, secure, expected_parts, forbidden_parts
|
||||
):
|
||||
"""Test WebSocket URL generation variants."""
|
||||
request = mock_request
|
||||
request.headers = headers
|
||||
@@ -357,7 +361,9 @@ class TestLocalServerMoreCoverage:
|
||||
|
||||
def test_select_app_for_route_picks_default(self, server_with_no_apps, monkeypatch):
|
||||
default_app = App(name="D", slug="d", path=".", command="echo d", terminal=True)
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_default_app", lambda: default_app)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_default_app", lambda: default_app
|
||||
)
|
||||
assert server_with_no_apps._select_app_for_route("missing") == default_app
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -381,7 +387,9 @@ class TestLocalServerMoreCoverage:
|
||||
ws.send_bytes.assert_awaited_once_with(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_session_close_ends_session_and_closes_ws(self, server_with_no_apps, monkeypatch):
|
||||
async def test_handle_session_close_ends_session_and_closes_ws(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
ws = MagicMock()
|
||||
ws.close = AsyncMock()
|
||||
server_with_no_apps._websocket_connections["rk"] = ws
|
||||
@@ -403,18 +411,26 @@ class TestLocalServerMoreCoverage:
|
||||
assert "slug" not in server_with_no_apps.session_manager.apps_by_slug
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_screenshot_404_when_no_running_session(self, server_with_no_apps, monkeypatch):
|
||||
async def test_handle_screenshot_404_when_no_running_session(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
request = MagicMock()
|
||||
request.query = {}
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_first_running_session", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_first_running_session", lambda: None
|
||||
)
|
||||
with pytest.raises(web.HTTPNotFound):
|
||||
await server_with_no_apps._handle_screenshot(request)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_screenshot_404_when_session_missing_buffer(self, server_with_no_apps, monkeypatch):
|
||||
async def test_handle_screenshot_404_when_session_missing_buffer(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
request = MagicMock()
|
||||
request.query = {"route_key": "rk"}
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: object())
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: object()
|
||||
)
|
||||
with pytest.raises(web.HTTPNotFound):
|
||||
await server_with_no_apps._handle_screenshot(request)
|
||||
|
||||
@@ -427,7 +443,9 @@ class TestLocalServerMoreCoverage:
|
||||
assert url.startswith("ws://")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_root_terminal_page_includes_assets_and_dataset(self, server_with_no_apps, monkeypatch):
|
||||
async def test_root_terminal_page_includes_assets_and_dataset(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
server_with_no_apps.session_manager.apps_by_slug["rk"] = App(
|
||||
name="Known",
|
||||
slug="rk",
|
||||
@@ -482,7 +500,6 @@ class TestLocalServerMoreCoverage:
|
||||
server_with_no_apps._route_last_activity["rk"] = -100.0
|
||||
assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 20.0
|
||||
|
||||
|
||||
def test_on_keyboard_interrupt_sets_event_when_already_shutting_down(self, server_with_no_apps):
|
||||
server_with_no_apps._shutdown_started = True
|
||||
assert not server_with_no_apps.exit_event.is_set()
|
||||
@@ -490,7 +507,9 @@ class TestLocalServerMoreCoverage:
|
||||
assert server_with_no_apps.exit_event.is_set()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_on_keyboard_interrupt_schedules_shutdown_in_running_loop(self, server_with_no_apps):
|
||||
async def test_on_keyboard_interrupt_schedules_shutdown_in_running_loop(
|
||||
self, server_with_no_apps
|
||||
):
|
||||
called = {"shutdown": False}
|
||||
|
||||
async def shutdown():
|
||||
@@ -532,7 +551,9 @@ class TestLocalServerMoreCoverage:
|
||||
schedule()
|
||||
assert created["called"] is True
|
||||
|
||||
def test_build_routes_logs_error_when_static_path_missing(self, server_with_no_apps, monkeypatch):
|
||||
def test_build_routes_logs_error_when_static_path_missing(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from textual_webterm import local_server
|
||||
@@ -548,11 +569,15 @@ class TestLocalServerMoreCoverage:
|
||||
local_server.log.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_ws_message_stdin_without_payload_sends_empty(self, server_with_no_apps, monkeypatch):
|
||||
async def test_dispatch_ws_message_stdin_without_payload_sends_empty(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||
session.send_bytes = AsyncMock()
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
|
||||
)
|
||||
|
||||
ws = MagicMock()
|
||||
created = await server_with_no_apps._dispatch_ws_message(["stdin"], "rk", ws, False)
|
||||
@@ -561,11 +586,15 @@ class TestLocalServerMoreCoverage:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_ws_message_resize_existing_session_flag_false(self, server_with_no_apps, monkeypatch):
|
||||
async def test_dispatch_ws_message_resize_existing_session_flag_false(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||
session.set_terminal_size = AsyncMock()
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
|
||||
)
|
||||
|
||||
ws = MagicMock()
|
||||
created = await server_with_no_apps._dispatch_ws_message(
|
||||
@@ -574,11 +603,15 @@ class TestLocalServerMoreCoverage:
|
||||
assert created is False
|
||||
session.set_terminal_size.assert_awaited_once_with(100, 50)
|
||||
|
||||
async def test_dispatch_ws_message_resize_updates_existing_session(self, server_with_no_apps, monkeypatch):
|
||||
async def test_dispatch_ws_message_resize_updates_existing_session(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get_screen_has_changes = AsyncMock(return_value=False)
|
||||
session.set_terminal_size = AsyncMock()
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session
|
||||
)
|
||||
|
||||
ws = MagicMock()
|
||||
created = await server_with_no_apps._dispatch_ws_message(
|
||||
@@ -588,8 +621,12 @@ class TestLocalServerMoreCoverage:
|
||||
session.set_terminal_size.assert_awaited_once_with(100, 50)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_ws_message_resize_no_session_noop(self, server_with_no_apps, monkeypatch):
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: None)
|
||||
async def test_dispatch_ws_message_resize_no_session_noop(
|
||||
self, server_with_no_apps, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: None
|
||||
)
|
||||
|
||||
ws = MagicMock()
|
||||
created = await server_with_no_apps._dispatch_ws_message(
|
||||
@@ -602,7 +639,11 @@ class TestLocalServerMoreCoverage:
|
||||
self, server_with_no_apps, monkeypatch, mock_request, mock_session
|
||||
):
|
||||
mock_session.get_screen_state = AsyncMock(return_value=(80, 24, [], False))
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: mock_session)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager,
|
||||
"get_session_by_route_key",
|
||||
lambda _rk: mock_session,
|
||||
)
|
||||
|
||||
request = mock_request
|
||||
request.query = {"route_key": "rk"}
|
||||
@@ -625,7 +666,11 @@ class TestLocalServerMoreCoverage:
|
||||
|
||||
screen_buffer = screen_buffer_factory(["line1", "line2"])
|
||||
mock_session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True))
|
||||
monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: mock_session)
|
||||
monkeypatch.setattr(
|
||||
server_with_no_apps.session_manager,
|
||||
"get_session_by_route_key",
|
||||
lambda _rk: mock_session,
|
||||
)
|
||||
|
||||
server_with_no_apps._route_last_activity["rk"] = 1.0
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ async def _make_client(server: LocalServer) -> TestClient:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_creates_session_on_resize(tmp_path):
|
||||
config = Config(apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)])
|
||||
config = Config(
|
||||
apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)]
|
||||
)
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
server = LocalServer(config_path=str(config_file), config=config)
|
||||
@@ -78,10 +80,11 @@ async def test_websocket_creates_session_on_resize(tmp_path):
|
||||
assert called["stdin"] == 1
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_ping_pong(tmp_path):
|
||||
config = Config(apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)])
|
||||
config = Config(
|
||||
apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)]
|
||||
)
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
server = LocalServer(config_path=str(config_file), config=config)
|
||||
@@ -102,7 +105,9 @@ async def test_websocket_ping_pong(tmp_path):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_websocket_ignores_invalid_envelopes(tmp_path):
|
||||
config = Config(apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)])
|
||||
config = Config(
|
||||
apps=[App(name="Test", slug="test", path=".", command="echo test", terminal=True)]
|
||||
)
|
||||
config_file = tmp_path / "config.toml"
|
||||
config_file.write_text("")
|
||||
server = LocalServer(config_path=str(config_file), config=config)
|
||||
|
||||
+67
-34
@@ -231,6 +231,7 @@ class TestRenderTerminalSvg:
|
||||
assert f'fill="{ANSI_COLORS["green"]}"' in svg
|
||||
# Check rect exists with green fill
|
||||
import re
|
||||
|
||||
rect_match = re.search(r'<rect[^>]*fill="{}"[^>]*/>'.format(ANSI_COLORS["green"]), svg)
|
||||
assert rect_match is not None
|
||||
|
||||
@@ -248,11 +249,13 @@ class TestRenderTerminalSvg:
|
||||
|
||||
def test_background_color_multiple_spans(self) -> None:
|
||||
"""Multiple background colors in same row render correctly."""
|
||||
buffer = [[
|
||||
self._char("R", bg="red"),
|
||||
self._char("G", bg="green"),
|
||||
self._char("B", bg="blue"),
|
||||
]]
|
||||
buffer = [
|
||||
[
|
||||
self._char("R", bg="red"),
|
||||
self._char("G", bg="green"),
|
||||
self._char("B", bg="blue"),
|
||||
]
|
||||
]
|
||||
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||
assert f'fill="{ANSI_COLORS["red"]}"' in svg
|
||||
assert f'fill="{ANSI_COLORS["green"]}"' in svg
|
||||
@@ -262,17 +265,21 @@ class TestRenderTerminalSvg:
|
||||
|
||||
def test_background_color_wide_char(self) -> None:
|
||||
"""Background color on wide character spans correct width."""
|
||||
buffer = [[
|
||||
self._char("中", bg="red"),
|
||||
self._char("", bg="red"), # Placeholder inherits bg
|
||||
]]
|
||||
buffer = [
|
||||
[
|
||||
self._char("中", bg="red"),
|
||||
self._char("", bg="red"), # Placeholder inherits bg
|
||||
]
|
||||
]
|
||||
svg = render_terminal_svg(buffer, width=80, height=24, char_width=10.0)
|
||||
# Background should span 2 columns (20px width + 0.5px overlap)
|
||||
assert f'fill="{ANSI_COLORS["red"]}"' in svg
|
||||
# Verify rect width is for 2 columns plus overlap
|
||||
import re
|
||||
rect_match = re.search(r'<rect[^>]*width="(\d+\.?\d*)"[^>]*fill="{}"/>'
|
||||
.format(ANSI_COLORS["red"]), svg)
|
||||
|
||||
rect_match = re.search(
|
||||
r'<rect[^>]*width="(\d+\.?\d*)"[^>]*fill="{}"/>'.format(ANSI_COLORS["red"]), svg
|
||||
)
|
||||
assert rect_match is not None
|
||||
width = float(rect_match.group(1))
|
||||
assert width == 20.5 # 2 columns * 10.0 char_width + 0.5 overlap
|
||||
@@ -300,7 +307,7 @@ class TestRenderTerminalSvg:
|
||||
buffer = [[self._char("│")]] # Vertical line
|
||||
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||
# Box drawing chars rendered with transform for vertical scaling
|
||||
assert 'scale(1,1.2)' in svg
|
||||
assert "scale(1,1.2)" in svg
|
||||
# Should be a separate text element, not a tspan
|
||||
assert '<text x="' in svg
|
||||
|
||||
@@ -309,7 +316,7 @@ class TestRenderTerminalSvg:
|
||||
buffer = [[self._char("┌"), self._char("┐")]]
|
||||
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||
# Both corners should have scale transforms
|
||||
assert svg.count('scale(1,1.2)') == 2
|
||||
assert svg.count("scale(1,1.2)") == 2
|
||||
|
||||
def test_unicode_text(self) -> None:
|
||||
"""Unicode text is preserved."""
|
||||
@@ -368,7 +375,7 @@ class TestRenderTerminalSvg:
|
||||
def test_custom_background(self) -> None:
|
||||
"""Custom background color is applied."""
|
||||
svg = render_terminal_svg([], width=80, height=24, background="#1a1a1a")
|
||||
assert 'fill: #1a1a1a' in svg
|
||||
assert "fill: #1a1a1a" in svg
|
||||
|
||||
def test_style_definitions_present(self) -> None:
|
||||
"""CSS style definitions are included."""
|
||||
@@ -434,8 +441,19 @@ class TestSvgStructure:
|
||||
|
||||
def test_all_tags_closed(self) -> None:
|
||||
"""All opened tags are properly closed."""
|
||||
buffer = [[{"data": "X", "fg": "red", "bg": "blue", "bold": True,
|
||||
"italics": False, "underscore": False, "reverse": False}]]
|
||||
buffer = [
|
||||
[
|
||||
{
|
||||
"data": "X",
|
||||
"fg": "red",
|
||||
"bg": "blue",
|
||||
"bold": True,
|
||||
"italics": False,
|
||||
"underscore": False,
|
||||
"reverse": False,
|
||||
}
|
||||
]
|
||||
]
|
||||
svg = render_terminal_svg(buffer, width=80, height=24)
|
||||
|
||||
# Count opening and closing tags
|
||||
@@ -525,11 +543,13 @@ class TestEdgeCases:
|
||||
|
||||
def test_special_unicode_blocks(self) -> None:
|
||||
"""Unicode box drawing characters render (separately for precise positioning)."""
|
||||
buffer = [[
|
||||
self._char("┌"),
|
||||
self._char("─"),
|
||||
self._char("┐"),
|
||||
]]
|
||||
buffer = [
|
||||
[
|
||||
self._char("┌"),
|
||||
self._char("─"),
|
||||
self._char("┐"),
|
||||
]
|
||||
]
|
||||
svg = render_terminal_svg(buffer, width=3, height=1)
|
||||
# Box drawing chars are rendered separately for precise x positioning
|
||||
assert "┌" in svg
|
||||
@@ -538,24 +558,32 @@ class TestEdgeCases:
|
||||
|
||||
def test_horizontal_lines_render_without_textlength(self) -> None:
|
||||
"""Horizontal lines render without textLength (removed due to positioning issues)."""
|
||||
buffer = [[
|
||||
self._char("╭"),
|
||||
self._char("─"),
|
||||
self._char("─"),
|
||||
self._char("─"),
|
||||
self._char("╮"),
|
||||
]]
|
||||
buffer = [
|
||||
[
|
||||
self._char("╭"),
|
||||
self._char("─"),
|
||||
self._char("─"),
|
||||
self._char("─"),
|
||||
self._char("╮"),
|
||||
]
|
||||
]
|
||||
svg = render_terminal_svg(buffer, width=5, height=1)
|
||||
# Horizontal lines should NOT have textLength (causes visual offset issues)
|
||||
assert 'textLength=' not in svg
|
||||
assert 'lengthAdjust=' not in svg
|
||||
assert "textLength=" not in svg
|
||||
assert "lengthAdjust=" not in svg
|
||||
# But the characters should still be present
|
||||
assert "─" in svg or "───" in svg
|
||||
|
||||
def test_ansi_bright_colors(self) -> None:
|
||||
"""All bright ANSI colors render."""
|
||||
colors = ["brightred", "brightgreen", "brightyellow",
|
||||
"brightblue", "brightmagenta", "brightcyan"]
|
||||
colors = [
|
||||
"brightred",
|
||||
"brightgreen",
|
||||
"brightyellow",
|
||||
"brightblue",
|
||||
"brightmagenta",
|
||||
"brightcyan",
|
||||
]
|
||||
buffer = [[self._char("X", fg=c) for c in colors]]
|
||||
svg = render_terminal_svg(buffer, width=len(colors), height=1)
|
||||
for color in colors:
|
||||
@@ -571,8 +599,13 @@ class TestEdgeCases:
|
||||
|
||||
def test_all_attributes_at_once(self) -> None:
|
||||
"""Character with all attributes renders."""
|
||||
buffer = [[self._char("X", fg="red", bg="blue", bold=True,
|
||||
italics=True, underscore=True, reverse=True)]]
|
||||
buffer = [
|
||||
[
|
||||
self._char(
|
||||
"X", fg="red", bg="blue", bold=True, italics=True, underscore=True, reverse=True
|
||||
)
|
||||
]
|
||||
]
|
||||
svg = render_terminal_svg(buffer, width=1, height=1)
|
||||
assert "bold" in svg
|
||||
assert "italic" in svg
|
||||
|
||||
@@ -240,11 +240,17 @@ class TestTerminalSession:
|
||||
session = TerminalSession(mock_poller, "test-session", command)
|
||||
|
||||
with (
|
||||
patch("textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)) as mock_fork,
|
||||
patch(
|
||||
"textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)
|
||||
) as mock_fork,
|
||||
patch("textual_webterm.terminal_session.version", return_value="0.0.0"),
|
||||
patch("textual_webterm.terminal_session.shlex.split", wraps=shlex.split) as mock_split,
|
||||
patch("textual_webterm.terminal_session.os.execvp", side_effect=OSError()) as mock_execvp,
|
||||
patch("textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
|
||||
patch(
|
||||
"textual_webterm.terminal_session.os.execvp", side_effect=OSError()
|
||||
) as mock_execvp,
|
||||
patch(
|
||||
"textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
||||
) as mock_exit,
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
await session.open()
|
||||
@@ -281,7 +287,9 @@ class TestTerminalSession:
|
||||
with (
|
||||
patch("textual_webterm.terminal_session.pty.fork", return_value=(pty.CHILD, 123)),
|
||||
patch("textual_webterm.terminal_session.shlex.split", side_effect=ValueError("bad")),
|
||||
patch("textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)) as mock_exit,
|
||||
patch(
|
||||
"textual_webterm.terminal_session.os._exit", side_effect=SystemExit(1)
|
||||
) as mock_exit,
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
await session.open()
|
||||
@@ -300,8 +308,10 @@ class TestTerminalSession:
|
||||
class DummyLock:
|
||||
async def __aenter__(self):
|
||||
return None
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
session._screen_lock = DummyLock()
|
||||
|
||||
lines = await session.get_screen_lines()
|
||||
@@ -317,14 +327,22 @@ class TestTerminalSession:
|
||||
session._screen.columns = 1
|
||||
session._screen.lines = 1
|
||||
session._screen.dirty = set()
|
||||
session._screen.buffer = [[MagicMock(data=" ", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False)]]
|
||||
session._screen.buffer = [
|
||||
[
|
||||
MagicMock(
|
||||
data=" ", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False
|
||||
)
|
||||
]
|
||||
]
|
||||
session._sync_pyte_to_pty = AsyncMock()
|
||||
|
||||
class DummyLock:
|
||||
async def __aenter__(self):
|
||||
return None
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
session._screen_lock = DummyLock()
|
||||
|
||||
width, height, _buffer, changed = await session.get_screen_state()
|
||||
@@ -342,15 +360,25 @@ class TestTerminalSession:
|
||||
session._screen.columns = 2
|
||||
session._screen.lines = 1
|
||||
session._screen.dirty = {1}
|
||||
session._screen.buffer = [[MagicMock(data="x", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False),
|
||||
MagicMock(data="y", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False)]]
|
||||
session._screen.buffer = [
|
||||
[
|
||||
MagicMock(
|
||||
data="x", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False
|
||||
),
|
||||
MagicMock(
|
||||
data="y", fg=0, bg=0, bold=False, italics=False, underscore=False, reverse=False
|
||||
),
|
||||
]
|
||||
]
|
||||
session._sync_pyte_to_pty = AsyncMock()
|
||||
|
||||
class DummyLock:
|
||||
async def __aenter__(self):
|
||||
return None
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
session._screen_lock = DummyLock()
|
||||
|
||||
width, height, _buffer, changed = await session.get_screen_state()
|
||||
@@ -367,11 +395,14 @@ class TestTerminalSession:
|
||||
session = TerminalSession(poller, "sid", "bash")
|
||||
session._screen = MagicMock()
|
||||
session._screen.dirty = {1}
|
||||
|
||||
class DummyLock:
|
||||
async def __aenter__(self):
|
||||
return None
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
session._screen_lock = DummyLock()
|
||||
session._sync_pyte_to_pty = AsyncMock()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user