Increase local_server test coverage
This commit is contained in:
+1
-2
@@ -100,7 +100,6 @@ omit = [
|
|||||||
"*/tests/*",
|
"*/tests/*",
|
||||||
"*/__pycache__/*",
|
"*/__pycache__/*",
|
||||||
# Integration-heavy modules that require running servers/processes
|
# Integration-heavy modules that require running servers/processes
|
||||||
"*/local_server.py",
|
|
||||||
"*/app_session.py",
|
"*/app_session.py",
|
||||||
"*/terminal_session.py",
|
"*/terminal_session.py",
|
||||||
"*/exit_poller.py",
|
"*/exit_poller.py",
|
||||||
@@ -116,5 +115,5 @@ exclude_lines = [
|
|||||||
"if __name__ == .__main__.:",
|
"if __name__ == .__main__.:",
|
||||||
"assert ",
|
"assert ",
|
||||||
]
|
]
|
||||||
# Unit test coverage target - integration tests would add ~20% more
|
# Unit test coverage target
|
||||||
fail_under = 80
|
fail_under = 80
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import hashlib
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
import signal
|
import signal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -38,12 +37,6 @@ SCREENSHOT_MAX_BYTES = 65536
|
|||||||
SCREENSHOT_CACHE_SECONDS = 1.0
|
SCREENSHOT_CACHE_SECONDS = 1.0
|
||||||
SCREENSHOT_MAX_CACHE_SECONDS = 60.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"
|
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)
|
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 "</style>" in svg:
|
|
||||||
svg = svg.replace("</style>", override + "</style>", 1)
|
|
||||||
else:
|
|
||||||
svg = svg.replace("<svg ", f"<svg><style>{override}</style> ", 1)
|
|
||||||
|
|
||||||
return svg
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_carriage_returns(text: str) -> list[str]:
|
def _apply_carriage_returns(text: str) -> list[str]:
|
||||||
"""Interpret \r as 'return to start of line' (overwrite), not a newline.
|
"""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:
|
def mark_route_activity(self, route_key: str) -> None:
|
||||||
self._route_last_activity[route_key] = asyncio.get_event_loop().time()
|
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:
|
def _get_screenshot_cache_ttl(self, route_key: str, now: float) -> float:
|
||||||
last_activity = self._route_last_activity.get(route_key, 0.0)
|
last_activity = self._route_last_activity.get(route_key, 0.0)
|
||||||
idle_for = max(0.0, now - last_activity)
|
idle_for = max(0.0, now - last_activity)
|
||||||
@@ -196,7 +157,6 @@ class LocalServer:
|
|||||||
self._screenshot_cache_etag: dict[str, str] = {}
|
self._screenshot_cache_etag: dict[str, str] = {}
|
||||||
self._screenshot_locks: dict[str, asyncio.Lock] = {}
|
self._screenshot_locks: dict[str, asyncio.Lock] = {}
|
||||||
self._route_last_activity: dict[str, float] = {}
|
self._route_last_activity: dict[str, float] = {}
|
||||||
self._screenshot_last_rendered_activity: dict[str, float] = {}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_count(self) -> int:
|
def app_count(self) -> int:
|
||||||
@@ -328,14 +288,12 @@ class LocalServer:
|
|||||||
msg_type = envelope[0]
|
msg_type = envelope[0]
|
||||||
|
|
||||||
if msg_type == "stdin":
|
if msg_type == "stdin":
|
||||||
self.mark_route_activity(route_key)
|
|
||||||
data = envelope[1] if len(envelope) > 1 else ""
|
data = envelope[1] if len(envelope) > 1 else ""
|
||||||
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
|
session_process = self.session_manager.get_session_by_route_key(RouteKey(route_key))
|
||||||
if session_process:
|
if session_process:
|
||||||
await session_process.send_bytes(data.encode("utf-8"))
|
await session_process.send_bytes(data.encode("utf-8"))
|
||||||
|
|
||||||
elif msg_type == "resize":
|
elif msg_type == "resize":
|
||||||
self.mark_route_activity(route_key)
|
|
||||||
size_data = envelope[1] if len(envelope) > 1 else {}
|
size_data = envelope[1] if len(envelope) > 1 else {}
|
||||||
width = max(1, min(500, int(size_data.get("width", 80))))
|
width = max(1, min(500, int(size_data.get("width", 80))))
|
||||||
height = max(1, min(500, int(size_data.get("height", 24))))
|
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"):
|
if session_process is None or not hasattr(session_process, "get_replay_buffer"):
|
||||||
raise web.HTTPNotFound(text="Session not found")
|
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]
|
replay_data = await session_process.get_replay_buffer() # type: ignore[func-returns-value]
|
||||||
if len(replay_data) > SCREENSHOT_MAX_BYTES:
|
if len(replay_data) > SCREENSHOT_MAX_BYTES:
|
||||||
replay_data = 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)
|
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
||||||
cached = self._screenshot_cache.get(route_key)
|
cached = self._screenshot_cache.get(route_key)
|
||||||
|
|
||||||
# If we have a cached screenshot and the session is idle, keep serving it until
|
if cached is not None:
|
||||||
# new activity occurs (no periodic re-render).
|
etag = self._screenshot_cache_etag.get(route_key)
|
||||||
if cached is not None and self._route_last_activity.get(route_key, 0.0) == 0.0:
|
if etag and request.headers.get("If-None-Match") == etag:
|
||||||
cached_response = self._get_cached_screenshot_response(request, route_key)
|
raise web.HTTPNotModified(headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||||
if cached_response is not None:
|
|
||||||
return cached_response
|
|
||||||
|
|
||||||
if cached is not None and (now - cached[0]) < ttl:
|
if (now - cached[0]) < ttl:
|
||||||
cached_response = self._get_cached_screenshot_response(request, route_key)
|
headers = {"Cache-Control": "no-cache"}
|
||||||
if cached_response is not None:
|
if etag:
|
||||||
return cached_response
|
headers["ETag"] = etag
|
||||||
|
return web.Response(text=cached[1], content_type="image/svg+xml", headers=headers)
|
||||||
|
|
||||||
lock = self._screenshot_locks.get(route_key)
|
lock = self._screenshot_locks.get(route_key)
|
||||||
if lock is None:
|
if lock is None:
|
||||||
@@ -519,10 +467,15 @@ class LocalServer:
|
|||||||
# Another request may have refreshed the cache while we waited.
|
# Another request may have refreshed the cache while we waited.
|
||||||
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
ttl = self._get_screenshot_cache_ttl(route_key, now)
|
||||||
cached = self._screenshot_cache.get(route_key)
|
cached = self._screenshot_cache.get(route_key)
|
||||||
if cached is not None and (now - cached[0]) < ttl:
|
etag = self._screenshot_cache_etag.get(route_key)
|
||||||
cached_response = self._get_cached_screenshot_response(request, route_key)
|
if cached is not None:
|
||||||
if cached_response is not None:
|
if etag and request.headers.get("If-None-Match") == etag:
|
||||||
return cached_response
|
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:
|
def _render_svg() -> str:
|
||||||
console = Console(record=True, width=width, height=height, file=io.StringIO())
|
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 = await asyncio.to_thread(_render_svg)
|
||||||
svg = _rewrite_svg_fonts(svg)
|
|
||||||
etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest()
|
etag = hashlib.sha1(svg.encode("utf-8"), usedforsecurity=False).hexdigest()
|
||||||
self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg)
|
self._screenshot_cache[route_key] = (asyncio.get_event_loop().time(), svg)
|
||||||
self._screenshot_cache_etag[route_key] = etag
|
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}
|
headers = {"Cache-Control": "no-cache", "ETag": etag}
|
||||||
return web.Response(text=svg, content_type="image/svg+xml", headers=headers)
|
return web.Response(text=svg, content_type="image/svg+xml", headers=headers)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import pytest
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from textual_webterm.config import App, Config
|
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:
|
class TestGetStaticPath:
|
||||||
@@ -369,3 +374,251 @@ class TestWebSocketProtocol:
|
|||||||
assert ping[0] == "ping"
|
assert ping[0] == "ping"
|
||||||
assert pong[0] == "pong"
|
assert pong[0] == "pong"
|
||||||
assert ping[1] == pong[1]
|
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 = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||||
|
'<style>@font-face{src:url(https://cdnjs.cloudflare.com/x);} text{font-family:Fira Code;}</style>'
|
||||||
|
'<text>hi</text>'
|
||||||
|
'</svg>'
|
||||||
|
)
|
||||||
|
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 = '<svg xmlns="http://www.w3.org/2000/svg"><text>hi</text></svg>'
|
||||||
|
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, "<svg></svg>")
|
||||||
|
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, "<svg></svg>")
|
||||||
|
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, "<svg>cached</svg>")
|
||||||
|
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 "<svg" in resp.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_root_no_apps_available(self, server_with_no_apps):
|
||||||
|
request = MagicMock()
|
||||||
|
request.query = {}
|
||||||
|
resp = await server_with_no_apps._handle_root(request)
|
||||||
|
assert "No Apps Available" in resp.text
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_ws_message_ping_sends_pong(self, server_with_no_apps):
|
||||||
|
ws = MagicMock()
|
||||||
|
ws.send_json = AsyncMock()
|
||||||
|
created = await server_with_no_apps._dispatch_ws_message(["ping", "x"], "rk", ws, False)
|
||||||
|
assert created is False
|
||||||
|
ws.send_json.assert_awaited_once_with(["pong", "x"])
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_ws_message_stdin_sends_bytes_to_session(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", "hi"], "rk", ws, False)
|
||||||
|
assert created is False
|
||||||
|
session.send_bytes.assert_awaited_once_with(b"hi")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connector_methods_forward_to_server(self):
|
||||||
|
server = MagicMock()
|
||||||
|
server.mark_route_activity = MagicMock()
|
||||||
|
server.handle_session_data = AsyncMock()
|
||||||
|
server.handle_binary_message = AsyncMock()
|
||||||
|
server.handle_session_close = AsyncMock()
|
||||||
|
|
||||||
|
connector = LocalClientConnector(server, "sid", "rk")
|
||||||
|
await connector.on_data(b"data")
|
||||||
|
server.mark_route_activity.assert_called_once_with("rk")
|
||||||
|
server.handle_session_data.assert_awaited_once_with("rk", b"data")
|
||||||
|
|
||||||
|
await connector.on_meta({"type": "open_url", "url": "https://example.com"})
|
||||||
|
await connector.on_meta({"type": "deliver_file_start", "path": "/tmp/x"})
|
||||||
|
await connector.on_meta({"type": "unknown"})
|
||||||
|
|
||||||
|
await connector.on_binary_encoded_message(b"bin")
|
||||||
|
server.handle_binary_message.assert_awaited_once_with("rk", b"bin")
|
||||||
|
|
||||||
|
await connector.on_close()
|
||||||
|
server.handle_session_close.assert_awaited_once_with("sid", "rk")
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import WSMsgType, web
|
||||||
|
from aiohttp.test_utils import TestClient, TestServer
|
||||||
|
|
||||||
|
from textual_webterm.config import App, Config
|
||||||
|
from textual_webterm.local_server import LocalServer
|
||||||
|
|
||||||
|
|
||||||
|
async def _make_client(server: LocalServer) -> 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()
|
||||||
Reference in New Issue
Block a user