diff --git a/pyproject.toml b/pyproject.toml index 3acb548..3426741 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,7 +100,6 @@ omit = [ "*/tests/*", "*/__pycache__/*", # Integration-heavy modules that require running servers/processes - "*/local_server.py", "*/app_session.py", "*/terminal_session.py", "*/exit_poller.py", @@ -116,5 +115,5 @@ exclude_lines = [ "if __name__ == .__main__.:", "assert ", ] -# Unit test coverage target - integration tests would add ~20% more +# Unit test coverage target fail_under = 80 diff --git a/src/textual_webterm/local_server.py b/src/textual_webterm/local_server.py index 26d588d..ca0550b 100644 --- a/src/textual_webterm/local_server.py +++ b/src/textual_webterm/local_server.py @@ -8,7 +8,6 @@ import hashlib import io import json import logging -import re import signal from pathlib import Path from typing import TYPE_CHECKING @@ -38,12 +37,6 @@ SCREENSHOT_MAX_BYTES = 65536 SCREENSHOT_CACHE_SECONDS = 1.0 SCREENSHOT_MAX_CACHE_SECONDS = 60.0 -SVG_MONO_FONT_STACK = ( - 'ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", ' - '"Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", ' - '"DejaVu Sans Mono", "Courier New", monospace' -) - WEBTERM_STATIC_PATH = Path(__file__).parent / "static" @@ -91,22 +84,6 @@ class LocalClientConnector(SessionConnector): await self.server.handle_session_close(self.session_id, self.route_key) -def _rewrite_svg_fonts(svg: str) -> str: - """Make Rich SVG output self-contained and aligned with our monospace styling.""" - - # Rich export_svg embeds @font-face rules that reference external CDNs. - svg = re.sub(r"@font-face\s*\{.*?\}\s*", "", svg, flags=re.DOTALL) - - # Force our local monospace stack even if Rich sets font-family to Fira Code. - override = f"\ntext {{ font-family: {SVG_MONO_FONT_STACK} !important; }}\n" - if "" in svg: - svg = svg.replace("", override + "", 1) - else: - svg = svg.replace(" ", 1) - - return svg - - def _apply_carriage_returns(text: str) -> list[str]: """Interpret \r as 'return to start of line' (overwrite), not a newline. @@ -133,22 +110,6 @@ class LocalServer: def mark_route_activity(self, route_key: str) -> None: self._route_last_activity[route_key] = asyncio.get_event_loop().time() - def _get_cached_screenshot_response( - self, request: web.Request, route_key: str - ) -> web.Response | None: - cached = self._screenshot_cache.get(route_key) - if cached is None: - return None - - etag = self._screenshot_cache_etag.get(route_key) - if etag and request.headers.get("If-None-Match") == etag: - raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"}) - - headers = {"Cache-Control": "no-cache"} - if etag: - headers["ETag"] = etag - return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers) - def _get_screenshot_cache_ttl(self, route_key: str, now: float) -> float: last_activity = self._route_last_activity.get(route_key, 0.0) idle_for = max(0.0, now - last_activity) @@ -196,7 +157,6 @@ class LocalServer: self._screenshot_cache_etag: dict[str, str] = {} self._screenshot_locks: dict[str, asyncio.Lock] = {} self._route_last_activity: dict[str, float] = {} - self._screenshot_last_rendered_activity: dict[str, float] = {} @property def app_count(self) -> int: @@ -328,14 +288,12 @@ class LocalServer: msg_type = envelope[0] if msg_type == "stdin": - self.mark_route_activity(route_key) data = envelope[1] if len(envelope) > 1 else "" session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key)) if session_process: await session_process.send_bytes(data.encode("utf-8")) elif msg_type == "resize": - self.mark_route_activity(route_key) size_data = envelope[1] if len(envelope) > 1 else {} width = max(1, min(500, int(size_data.get("width", 80)))) height = max(1, min(500, int(size_data.get("height", 24)))) @@ -464,15 +422,6 @@ class LocalServer: if session_process is None or not hasattr(session_process, "get_replay_buffer"): raise web.HTTPNotFound(text="Session not found") - # If nothing has changed since the last render, serve cached screenshot without - # touching the session replay buffer. - last_activity = self._route_last_activity.get(route_key, 0.0) - last_rendered_activity = self._screenshot_last_rendered_activity.get(route_key, -1.0) - if last_activity <= last_rendered_activity: - cached_response = self._get_cached_screenshot_response(request, route_key) - if cached_response is not None: - return cached_response - replay_data = await session_process.get_replay_buffer() # type: ignore[func-returns-value] if len(replay_data) > SCREENSHOT_MAX_BYTES: replay_data = replay_data[-SCREENSHOT_MAX_BYTES:] @@ -498,17 +447,16 @@ class LocalServer: ttl = self._get_screenshot_cache_ttl(route_key, now) cached = self._screenshot_cache.get(route_key) - # If we have a cached screenshot and the session is idle, keep serving it until - # new activity occurs (no periodic re-render). - if cached is not None and self._route_last_activity.get(route_key, 0.0) == 0.0: - cached_response = self._get_cached_screenshot_response(request, route_key) - if cached_response is not None: - return cached_response + if cached is not None: + etag = self._screenshot_cache_etag.get(route_key) + if etag and request.headers.get("If-None-Match") == etag: + raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"}) - if cached is not None and (now - cached[0]) < ttl: - cached_response = self._get_cached_screenshot_response(request, route_key) - if cached_response is not None: - return cached_response + if (now - cached[0]) < ttl: + headers = {"Cache-Control": "no-cache"} + if etag: + headers["ETag"] = etag + return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers) lock = self._screenshot_locks.get(route_key) if lock is None: @@ -519,10 +467,15 @@ class LocalServer: # Another request may have refreshed the cache while we waited. ttl = self._get_screenshot_cache_ttl(route_key, now) cached = self._screenshot_cache.get(route_key) - if cached is not None and (now - cached[0]) < ttl: - cached_response = self._get_cached_screenshot_response(request, route_key) - if cached_response is not None: - return cached_response + etag = self._screenshot_cache_etag.get(route_key) + if cached is not None: + if etag and request.headers.get("If-None-Match") == etag: + raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"}) + if (now - cached[0]) < ttl: + headers = {"Cache-Control": "no-cache"} + if etag: + headers["ETag"] = etag + return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers) def _render_svg() -> str: console = Console(record=True, width=width, height=height, file=io.StringIO()) @@ -552,13 +505,9 @@ class LocalServer: ) svg = await asyncio.to_thread(_render_svg) - svg = _rewrite_svg_fonts(svg) etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest() self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg) self._screenshot_cache_etag[route_key] = etag - self._screenshot_last_rendered_activity[route_key] = self._route_last_activity.get( - route_key, 0.0 - ) headers = {"Cache-Control": "no-cache", "ETag": etag} return web.Response(text=svg, content_type="image/svg+xml", headers=headers) diff --git a/tests/test_local_server_unit.py b/tests/test_local_server_unit.py index 17e32a6..a04157a 100644 --- a/tests/test_local_server_unit.py +++ b/tests/test_local_server_unit.py @@ -6,7 +6,12 @@ import pytest from aiohttp import web from textual_webterm.config import App, Config -from textual_webterm.local_server import LocalServer, _apply_carriage_returns +from textual_webterm.local_server import ( + LocalClientConnector, + LocalServer, + _apply_carriage_returns, + _rewrite_svg_fonts, +) class TestGetStaticPath: @@ -369,3 +374,251 @@ class TestWebSocketProtocol: assert ping[0] == "ping" assert pong[0] == "pong" assert ping[1] == pong[1] + + +class TestLocalServerMoreCoverage: + @pytest.fixture + def server_with_no_apps(self, tmp_path): + config = Config(apps=[]) + config_file = tmp_path / "config.toml" + config_file.write_text("") + return LocalServer(config_path=str(config_file), config=config, host="localhost", port=8080) + + @pytest.mark.asyncio + async def test_handle_health_check(self, server_with_no_apps): + resp = await server_with_no_apps._handle_health_check(MagicMock()) + assert resp.text == "Local server is running" + + 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) + assert server_with_no_apps._select_app_for_route("missing") == default_app + + @pytest.mark.asyncio + async def test_handle_session_data_no_ws_noop(self, server_with_no_apps): + await server_with_no_apps.handle_session_data("rk", b"data") + + @pytest.mark.asyncio + async def test_handle_session_data_sends_bytes(self, server_with_no_apps): + ws = MagicMock() + ws.send_bytes = AsyncMock() + server_with_no_apps._websocket_connections["rk"] = ws + await server_with_no_apps.handle_session_data("rk", b"data") + ws.send_bytes.assert_awaited_once_with(b"data") + + @pytest.mark.asyncio + async def test_handle_binary_message_sends_bytes(self, server_with_no_apps): + ws = MagicMock() + ws.send_bytes = AsyncMock() + server_with_no_apps._websocket_connections["rk"] = ws + await server_with_no_apps.handle_binary_message("rk", b"bin") + ws.send_bytes.assert_awaited_once_with(b"bin") + + @pytest.mark.asyncio + 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 + monkeypatch.setattr(server_with_no_apps.session_manager, "on_session_end", MagicMock()) + await server_with_no_apps.handle_session_close("sid", "rk") + server_with_no_apps.session_manager.on_session_end.assert_called_once_with("sid") + ws.close.assert_awaited_once() + + def test_force_exit_sets_event(self, server_with_no_apps): + assert not server_with_no_apps.exit_event.is_set() + server_with_no_apps.force_exit() + assert server_with_no_apps.exit_event.is_set() + + def test_get_static_path_import_error_returns_none(self, monkeypatch): + import builtins + + from textual_webterm.local_server import _get_static_path + + real_import = builtins.__import__ + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "textual_serve": + raise ImportError("nope") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(builtins, "__import__", fake_import) + assert _get_static_path() is None + + def test_add_terminal_windows_noop(self, server_with_no_apps, monkeypatch): + from textual_webterm import constants as constants_mod + + monkeypatch.setattr(constants_mod, "WINDOWS", True) + server_with_no_apps.add_terminal("T", "cmd", "slug") + 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): + request = MagicMock() + request.query = {} + 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): + request = MagicMock() + request.query = {"route_key": "rk"} + 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) + + @pytest.mark.asyncio + async def test_get_ws_url_falls_back_when_no_host_header(self, server_with_no_apps): + request = MagicMock() + request.headers = {} + request.secure = False + url = server_with_no_apps._get_ws_url_from_request(request, "rk") + assert url.startswith("ws://") + + @pytest.mark.asyncio + 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", + path=".", + command="echo", + terminal=True, + ) + request = MagicMock() + request.query = {"route_key": "rk"} + request.headers = {"Host": "localhost:8080"} + request.secure = False + + resp = await server_with_no_apps._handle_root(request) + assert "/static/css/xterm.css" in resp.text + assert "/static-webterm/monospace.css" in resp.text + assert "data-session-websocket-url" in resp.text + assert "data-font-size" 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 " TestClient: + app = web.Application() + app.add_routes(server._build_routes()) + test_server = TestServer(app) + client = TestClient(test_server) + await client.start_server() + return client + + +@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_file = tmp_path / "config.toml" + config_file.write_text("") + server = LocalServer(config_path=str(config_file), config=config) + + # Avoid spawning any real processes. + created = {"args": None} + + async def fake_create(route_key: str, width: int, height: int) -> None: + created["args"] = (route_key, width, height) + + server._create_terminal_session = fake_create # type: ignore[method-assign] + + client = await _make_client(server) + try: + ws = await client.ws_connect("/ws/test") + await ws.send_str(json.dumps(["resize", {"width": 90, "height": 25}])) + await ws.close() + finally: + await client.close() + + assert created["args"] == ("test", 90, 25) + + +@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_file = tmp_path / "config.toml" + config_file.write_text("") + server = LocalServer(config_path=str(config_file), config=config) + + client = await _make_client(server) + try: + ws = await client.ws_connect("/ws/test") + await ws.send_str(json.dumps(["ping", "123"])) + + msg = await ws.receive(timeout=1) + assert msg.type == WSMsgType.TEXT + assert json.loads(msg.data) == ["pong", "123"] + + await ws.close() + finally: + await client.close() + + +@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_file = tmp_path / "config.toml" + config_file.write_text("") + server = LocalServer(config_path=str(config_file), config=config) + + client = await _make_client(server) + try: + ws = await client.ws_connect("/ws/test") + await ws.send_str("not json") + await ws.send_str(json.dumps({"not": "a list"})) + await ws.send_str(json.dumps([])) + await ws.close() + finally: + await client.close()