"""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, ) class TestGetStaticPath: """Tests for static path.""" def test_static_path_exists(self): """Test that static path exists.""" from textual_webterm.local_server import WEBTERM_STATIC_PATH assert WEBTERM_STATIC_PATH is not None and WEBTERM_STATIC_PATH.exists() def test_static_path_has_js(self): """Test that static path has JS directory.""" from textual_webterm.local_server import WEBTERM_STATIC_PATH assert WEBTERM_STATIC_PATH is not None assert (WEBTERM_STATIC_PATH / "js").exists() def test_static_path_has_css(self): """Test that static path has CSS directory.""" from textual_webterm.local_server import WEBTERM_STATIC_PATH assert WEBTERM_STATIC_PATH is not None assert (WEBTERM_STATIC_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_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"} # Mock screen state: width=80, height=2, buffer with "hello" on first line screen_buffer = [ [{"data": c, "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False} for c in "hello" + " " * 75], [{"data": " ", "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False}] * 80, ] session = MagicMock() session.get_screen_state = AsyncMock(return_value=(80, 2, screen_buffer, True)) 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 @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_changes(self, server_with_no_apps, monkeypatch): """Test that cached screenshot is returned when pyte reports no changes.""" request = MagicMock() request.query = {"route_key": "rk"} request.headers = {} # has_changes=False indicates no screen changes since last call session = MagicMock() session.get_screen_state = AsyncMock(return_value=(80, 2, [], False)) 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 resp = await server_with_no_apps._handle_screenshot(request) assert "cached" in resp.text @pytest.mark.asyncio async def test_handle_screenshot_renders_screen_state(self, server_with_no_apps, monkeypatch): request = MagicMock() request.query = {"route_key": "rk"} request.headers = {} # Mock screen state with some content screen_buffer = [ [{"data": c, "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False} for c in "hello" + " " * 75], [{"data": " ", "fg": "default", "bg": "default", "bold": False, "italics": False, "underscore": False, "reverse": False}] * 80, ] session = MagicMock() 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: session) resp = await server_with_no_apps._handle_screenshot(request) assert resp.content_type == "image/svg+xml" assert "