"""Tests for local_server module - unit tests for helper functions.""" from unittest.mock import AsyncMock, MagicMock import pytest from aiohttp import web from textual_webterm.config import App, Config from textual_webterm.local_server import ( LocalClientConnector, LocalServer, _apply_carriage_returns, _rewrite_svg_fonts, ) class TestGetStaticPath: """Tests for static path function.""" def test_static_path_exists(self): """Test that static path exists.""" from textual_webterm.local_server import _get_static_path path = _get_static_path() assert path is not None and path.exists() def test_static_path_has_js(self): """Test that static path has JS directory.""" from textual_webterm.local_server import _get_static_path path = _get_static_path() assert path is not None assert (path / "js").exists() def test_static_path_has_css(self): """Test that static path has CSS directory.""" from textual_webterm.local_server import _get_static_path path = _get_static_path() assert path is not None assert (path / "css").exists() class TestLocalServer: """Tests for LocalServer class.""" @pytest.fixture def config(self): """Create a test config.""" return Config( apps=[ App(name="Test", slug="test", path="./", command="echo test", terminal=True), ], ) @pytest.fixture def server(self, config, tmp_path): """Create a test server.""" config_file = tmp_path / "config.toml" config_file.write_text("") return LocalServer( config_path=str(config_file), config=config, host="localhost", port=8080, ) def test_init(self, server): """Test LocalServer initialization.""" assert server.host == "localhost" assert server.port == 8080 assert server.session_manager is not None def test_add_app(self, server): """Test adding an app.""" server.add_app("New App", "python app.py", "newapp") assert "newapp" in server.session_manager.apps_by_slug def test_add_terminal(self, server): """Test adding a terminal.""" server.add_terminal("Terminal", "bash", "term") assert "term" in server.session_manager.apps_by_slug app = server.session_manager.apps_by_slug["term"] assert app.terminal is True @pytest.mark.asyncio async def test_create_terminal_session_uses_slug_and_starts_session(self, server, monkeypatch): from textual_webterm import local_server monkeypatch.setattr(local_server, "generate", lambda: "fixed-session") session = MagicMock() session.start = AsyncMock() monkeypatch.setattr(server.session_manager, "new_session", AsyncMock(return_value=session)) await server._create_terminal_session("test", 80, 24) server.session_manager.new_session.assert_awaited_once_with( "test", "fixed-session", "test", size=(80, 24), ) session.start.assert_awaited_once() connector = session.start.call_args.args[0] assert connector.session_id == "fixed-session" assert connector.route_key == "test" class TestLocalServerHelpers: """Tests for LocalServer helper methods.""" def test_apply_carriage_returns_overwrites_line(self): text = "hello\rworld\r\nnext" # pyte terminal emulator interprets CR properly - overwrites hello with world lines = _apply_carriage_returns(text, width=80, height=24) # First line should have "world" (overwritten), second line "next" assert lines[0] == "world" assert lines[1] == "next" def test_apply_carriage_returns_handles_cursor_positioning(self): # Simulate tmux-style cursor positioning to row 5, column 1 (\x1b[5;1H) # Then clear to end of line (\x1b[K) and write new content # Use \r\n for proper line endings text = "line1\r\nline2\r\nline3\r\nline4\r\nline5\x1b[5;1H\x1b[Kupdated" lines = _apply_carriage_returns(text, width=80, height=10) # Line 5 (index 4) should be overwritten with "updated" assert lines[4] == "updated" # Previous lines should remain assert lines[0] == "line1" assert lines[1] == "line2" @pytest.mark.asyncio async def test_keyboard_interrupt_closes_sessions_and_websockets(self, server, monkeypatch): ws1 = MagicMock() ws1.close = AsyncMock() ws2 = MagicMock() ws2.close = AsyncMock() server._websocket_connections["a"] = ws1 server._websocket_connections["b"] = ws2 monkeypatch.setattr(server.session_manager, "close_all", AsyncMock()) server.on_keyboard_interrupt() assert server._shutdown_task is not None await server._shutdown_task ws1.close.assert_awaited_once() ws2.close.assert_awaited_once() server.session_manager.close_all.assert_awaited_once() assert server.exit_event.is_set() @pytest.mark.asyncio async def test_ws_resize_creates_session_when_slug_exists(self, server, monkeypatch): server.session_manager.apps_by_slug["slug"] = App( name="Known", slug="slug", path="./", command="echo ok", terminal=True, ) monkeypatch.setattr(server, "_create_terminal_session", AsyncMock()) ws = MagicMock() session_created = await server._dispatch_ws_message( ["resize", {"width": 100, "height": 40}], "slug", ws, session_created=False, ) assert session_created is True server._create_terminal_session.assert_awaited_once_with("slug", 100, 40) @pytest.mark.asyncio async def test_ws_resize_sends_error_if_no_apps(self, server): ws = MagicMock() ws.send_json = AsyncMock() server._websocket_connections["rk"] = ws session_created = await server._dispatch_ws_message( ["resize", {"width": 80, "height": 24}], "rk", ws, session_created=False, ) assert session_created is True ws.send_json.assert_awaited_once_with(["error", "No app configured"]) @pytest.mark.asyncio async def test_resize_on_disconnect_calls_set_terminal_size(self, server, monkeypatch): session = MagicMock() session.set_terminal_size = AsyncMock() monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session) await server._resize_on_disconnect("rk") session.set_terminal_size.assert_called_once_with(132, 45) @pytest.mark.asyncio async def test_create_terminal_session_sends_error_if_no_apps(self, server): ws = MagicMock() ws.send_json = AsyncMock() server._websocket_connections["rk"] = ws await server._create_terminal_session("rk", 80, 24) ws.send_json.assert_awaited_once_with(["error", "No app configured"]) @pytest.mark.asyncio async def test_screenshot_svg_handler_returns_svg(self, server, monkeypatch, capsys): request = MagicMock() request.query = {"route_key": "rk", "width": "80"} session = MagicMock() session.get_replay_buffer = AsyncMock(return_value=b"hello\r\n") monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: session) response = await server._handle_screenshot(request) assert response.content_type == "image/svg+xml" assert "Known" in resp.text def test_rewrite_svg_fonts_removes_font_face_and_forces_stack(self): svg = ( '' '' 'hi' '' ) out = _rewrite_svg_fonts(svg) assert "@font-face" not in out assert "cdnjs.cloudflare.com" not in out assert "ui-monospace" in out def test_rewrite_svg_fonts_injects_style_if_missing(self): svg = 'hi' out = _rewrite_svg_fonts(svg) assert "ui-monospace" in out @pytest.mark.asyncio async def test_cached_screenshot_etag_returns_304(self, server_with_no_apps): request = MagicMock() request.headers = {"If-None-Match": "abc"} server_with_no_apps._screenshot_cache["rk"] = (0.0, "") server_with_no_apps._screenshot_cache_etag["rk"] = "abc" with pytest.raises(web.HTTPNotModified): server_with_no_apps._get_cached_screenshot_response(request, "rk") @pytest.mark.asyncio async def test_cached_screenshot_etag_sets_headers(self, server_with_no_apps): request = MagicMock() request.headers = {} server_with_no_apps._screenshot_cache["rk"] = (0.0, "") server_with_no_apps._screenshot_cache_etag["rk"] = "abc" resp = server_with_no_apps._get_cached_screenshot_response(request, "rk") assert resp is not None assert resp.headers.get("ETag") == "abc" def test_screenshot_cache_ttl_backs_off(self, server_with_no_apps, monkeypatch): # Drive each tier by controlling now and last_activity. server_with_no_apps._route_last_activity["rk"] = 99.0 assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 1.0 assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=110.0) == 5.0 assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=200.0) == 15.0 assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=1000.0) == 60.0 @pytest.mark.asyncio async def test_handle_screenshot_uses_cache_when_no_new_activity(self, server_with_no_apps, monkeypatch): request = MagicMock() request.query = {"route_key": "rk"} request.headers = {} session = MagicMock() session.get_replay_buffer = AsyncMock(return_value=b"SHOULD_NOT_BE_READ") monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session) server_with_no_apps._screenshot_cache["rk"] = (0.0, "cached") server_with_no_apps._screenshot_cache_etag["rk"] = "etag" server_with_no_apps._route_last_activity["rk"] = 5.0 server_with_no_apps._screenshot_last_rendered_activity["rk"] = 5.0 resp = await server_with_no_apps._handle_screenshot(request) assert "cached" in resp.text session.get_replay_buffer.assert_not_awaited() @pytest.mark.asyncio async def test_handle_screenshot_invalid_width_height_defaults(self, server_with_no_apps, monkeypatch): request = MagicMock() request.query = {"route_key": "rk", "width": "nope", "height": "nope"} request.headers = {} session = MagicMock() session.get_replay_buffer = AsyncMock(return_value=b"hello\n") monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session) resp = await server_with_no_apps._handle_screenshot(request) assert resp.content_type == "image/svg+xml" assert " bool: # type: ignore[override] return False monkeypatch.setattr(local_server, "STATIC_PATH", FakePath("/definitely-missing")) monkeypatch.setattr(local_server.log, "error", MagicMock()) server_with_no_apps._build_routes() 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): session = MagicMock() session.send_bytes = AsyncMock() 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) assert created is False session.send_bytes.assert_awaited_once_with(b"") @pytest.mark.asyncio async def test_dispatch_ws_message_resize_updates_existing_session(self, server_with_no_apps, monkeypatch): session = MagicMock() session.set_terminal_size = AsyncMock() 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( ["resize", {"width": 100, "height": 50}], "rk", ws, True ) assert created is True 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) ws = MagicMock() created = await server_with_no_apps._dispatch_ws_message( ["resize", {"width": 100, "height": 50}], "rk", ws, True ) assert created is True @pytest.mark.asyncio async def test_handle_screenshot_truncates_replay_buffer_before_decode(self, server_with_no_apps, monkeypatch): from textual_webterm.local_server import SCREENSHOT_MAX_BYTES request = MagicMock() request.query = {"route_key": "rk"} request.headers = {} session = MagicMock() session.get_replay_buffer = AsyncMock(return_value=b"x" * (SCREENSHOT_MAX_BYTES + 10)) monkeypatch.setattr(server_with_no_apps.session_manager, "get_session_by_route_key", lambda _rk: session) server_with_no_apps._route_last_activity["rk"] = 1.0 captured = {"len": None} def apply_cr(text: str, width: int = 80, height: int = 24): captured["len"] = len(text) return ["x"] async def fake_to_thread(_fn): return "" monkeypatch.setattr("textual_webterm.local_server._apply_carriage_returns", apply_cr) monkeypatch.setattr("textual_webterm.local_server.asyncio.to_thread", AsyncMock(side_effect=fake_to_thread)) monkeypatch.setattr("textual_webterm.local_server._rewrite_svg_fonts", lambda s: s) resp = await server_with_no_apps._handle_screenshot(request) assert resp.content_type == "image/svg+xml" assert captured["len"] == SCREENSHOT_MAX_BYTES