"""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, _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.""" @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_screen_lines = AsyncMock(return_value=["hello", ""]) 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_screen_lines = AsyncMock(return_value=["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_screen_lines.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_screen_lines = AsyncMock(return_value=["hello", ""]) 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_uses_get_screen_lines(self, server_with_no_apps, monkeypatch): """Test that screenshot uses get_screen_lines() from terminal session.""" request = MagicMock() request.query = {"route_key": "rk"} request.headers = {} session = MagicMock() session.get_screen_lines = AsyncMock(return_value=["line1", "line2", ""]) 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 resp = await server_with_no_apps._handle_screenshot(request) assert resp.content_type == "image/svg+xml" assert "