"""Tests for local_server module - unit tests for helper functions.""" from unittest.mock import AsyncMock, MagicMock import pytest from aiohttp import web from webterm.config import App, Config from webterm.local_server import ( LocalServer, ) class TestGetStaticPath: """Tests for static path.""" def test_static_path_exists(self): """Test that static path exists.""" from 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 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_wasm(self): """Test that static path has WASM file.""" from webterm.local_server import WEBTERM_STATIC_PATH assert WEBTERM_STATIC_PATH is not None assert (WEBTERM_STATIC_PATH / "js" / "ghostty-vt.wasm").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 a terminal app.""" server.add_app("New Terminal", "bash", "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 webterm import local_server monkeypatch.setattr(local_server, "generate", lambda: "fixed-session") session = MagicMock() session.get_screen_has_changes = AsyncMock(return_value=False) 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, screen_buffer_factory, mock_session, mock_request ): request = mock_request request.query = {"route_key": "rk"} 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 ) 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): server_with_no_apps._route_last_activity["rk"] = 99.0 assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 0.3 server_with_no_apps._route_last_activity["rk"] = 90.0 assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 2.0 server_with_no_apps._route_last_activity["rk"] = 40.0 assert server_with_no_apps._get_screenshot_cache_ttl("rk", now=100.0) == 5.0 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() server_with_no_apps.on_keyboard_interrupt() 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 ): called = {"shutdown": False} async def shutdown(): called["shutdown"] = True server_with_no_apps.exit_event.set() server_with_no_apps._shutdown = shutdown # type: ignore[method-assign] server_with_no_apps.on_keyboard_interrupt() assert server_with_no_apps._shutdown_task is not None await server_with_no_apps._shutdown_task assert called["shutdown"] is True def test_on_keyboard_interrupt_uses_call_soon_threadsafe_when_loop_running( self, server_with_no_apps, monkeypatch ): async def shutdown(): return None server_with_no_apps._shutdown = shutdown # type: ignore[method-assign] fake_loop = MagicMock() fake_loop.is_running = MagicMock(return_value=True) server_with_no_apps._loop = fake_loop created = {"called": False} def fake_create_task(coro): created["called"] = True coro.close() return MagicMock() monkeypatch.setattr("webterm.local_server.asyncio.create_task", fake_create_task) server_with_no_apps.on_keyboard_interrupt() assert fake_loop.call_soon_threadsafe.called schedule = fake_loop.call_soon_threadsafe.call_args.args[0] schedule() assert created["called"] is True def test_build_routes_logs_error_when_static_path_missing( self, server_with_no_apps, monkeypatch ): from unittest.mock import MagicMock from webterm import local_server # Create a mock path that returns False for exists() fake_path = MagicMock() fake_path.exists.return_value = False monkeypatch.setattr(local_server, "WEBTERM_STATIC_PATH", fake_path) 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.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 ) 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 @pytest.mark.asyncio 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 ) ws = MagicMock() created = await server_with_no_apps._dispatch_ws_message( ["resize", {"width": 100, "height": 50}], "rk", ws, False ) 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 ): 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 ) 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_cached_when_no_changes( 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, ) request = mock_request request.query = {"route_key": "rk"} # Seed cache server_with_no_apps._screenshot_cache["rk"] = (0.0, "") server_with_no_apps._screenshot_cache_etag["rk"] = "abc" resp = await server_with_no_apps._handle_screenshot(request) assert resp.text == "" mock_session.get_screen_state.assert_not_awaited() @pytest.mark.asyncio async def test_handle_screenshot_uses_screen_state( self, server_with_no_apps, monkeypatch, screen_buffer_factory, mock_request, mock_session ): """Test that screenshot uses get_screen_state for rendering.""" request = mock_request request.query = {"route_key": "rk"} 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, ) 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 " 0.0 ws.send_bytes.assert_awaited_once_with(b"data") def test_mark_route_activity_triggers_notification(self, server_with_no_apps): """Test that mark_route_activity triggers SSE notification.""" import asyncio queue: asyncio.Queue[str] = asyncio.Queue(maxsize=10) server_with_no_apps._sse_subscribers.append(queue) server_with_no_apps.mark_route_activity("my-route") assert not queue.empty() assert queue.get_nowait() == "my-route"