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:
GitHub Copilot
2026-01-28 12:45:02 +00:00
parent 0fad9e7353
commit 216380405a
16 changed files with 957 additions and 153 deletions
+220
View File
@@ -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()
+67 -22
View File
@@ -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
View File
@@ -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
+38 -7
View File
@@ -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()