894fb2eaaf
Instead of trying to replay a truncated byte buffer through pyte, this change maintains a pyte Screen object within TerminalSession that gets updated as terminal data flows through. This provides accurate terminal state for screenshots without issues from buffer truncation. Key changes: - Add pyte Screen and Stream to TerminalSession - Update screen state as data arrives via _update_screen() - Add get_screen_lines() to return current screen state - Resize pyte screen when terminal size changes - Update local_server to use get_screen_lines() directly - Remove _apply_carriage_returns() workaround This properly fixes the tmux status bar 'creeping up' issue by ensuring the screenshot always reflects the actual terminal state.
754 lines
28 KiB
Python
754 lines
28 KiB
Python
"""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 "<svg" in response.text
|
|
|
|
out = capsys.readouterr()
|
|
assert out.out == ""
|
|
assert out.err == ""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_creates_session_for_known_slug(self, server, monkeypatch):
|
|
request = MagicMock()
|
|
request.query = {"route_key": "known", "width": "90"}
|
|
|
|
session = MagicMock()
|
|
session.get_screen_lines = AsyncMock(return_value=["world", ""])
|
|
|
|
# Pretend app exists for slug "known"
|
|
server.session_manager.apps_by_slug["known"] = App(
|
|
name="Known",
|
|
slug="known",
|
|
path="./",
|
|
command="echo world",
|
|
terminal=True,
|
|
)
|
|
|
|
created = {}
|
|
|
|
async def create_session(route_key, width, height):
|
|
created["called"] = (route_key, width, height)
|
|
server.session_manager.routes["known"] = "sid"
|
|
|
|
monkeypatch.setattr(server, "_create_terminal_session", create_session)
|
|
monkeypatch.setattr(
|
|
server.session_manager,
|
|
"get_session_by_route_key",
|
|
lambda _rk: session if created else None,
|
|
)
|
|
|
|
response = await server._handle_screenshot(request)
|
|
assert response.content_type == "image/svg+xml"
|
|
assert "<svg" in response.text
|
|
assert "ui-monospace" in response.text
|
|
assert "cdnjs.cloudflare.com" not in response.text
|
|
assert created["called"][0] == "known"
|
|
assert created["called"][1:] == (132, 45)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_returns_404_for_unknown_slug(self, server, monkeypatch):
|
|
request = MagicMock()
|
|
request.query = {"route_key": "unknown"}
|
|
|
|
monkeypatch.setattr(server.session_manager, "get_session_by_route_key", lambda _rk: None)
|
|
|
|
with pytest.raises(web.HTTPNotFound) as exc:
|
|
await server._handle_screenshot(request)
|
|
|
|
assert exc.value.status == 404
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_click_route_key_redirects(self, server):
|
|
request = MagicMock()
|
|
request.query = {}
|
|
server._landing_apps = [
|
|
App(name="Known", slug="known", path="./", command="echo world", terminal=True)
|
|
]
|
|
response = await server._handle_root(request)
|
|
assert "/?route_key=${encodeURIComponent(tile.slug)}" in response.text
|
|
assert "visibilitychange" in response.text
|
|
|
|
@pytest.fixture
|
|
def config(self):
|
|
"""Create a test config."""
|
|
return Config(
|
|
apps=[],
|
|
)
|
|
|
|
@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_get_ws_url_basic(self, server):
|
|
"""Test basic WebSocket URL generation."""
|
|
request = MagicMock()
|
|
request.headers = {"Host": "localhost:8080"}
|
|
request.secure = False
|
|
|
|
url = server._get_ws_url_from_request(request, "test-route")
|
|
assert "ws://" in url
|
|
assert "test-route" in url
|
|
|
|
def test_get_ws_url_secure(self, server):
|
|
"""Test secure WebSocket URL generation."""
|
|
request = MagicMock()
|
|
request.headers = {"Host": "localhost:8080", "X-Forwarded-Proto": "https"}
|
|
request.secure = True
|
|
|
|
url = server._get_ws_url_from_request(request, "test-route")
|
|
assert "wss://" in url
|
|
|
|
def test_get_ws_url_forwarded_host(self, server):
|
|
"""Test WebSocket URL with forwarded host."""
|
|
request = MagicMock()
|
|
request.headers = {
|
|
"Host": "localhost:8080",
|
|
"X-Forwarded-Host": "example.com",
|
|
"X-Forwarded-Proto": "https",
|
|
}
|
|
request.secure = False
|
|
|
|
url = server._get_ws_url_from_request(request, "test-route")
|
|
assert "example.com" in url
|
|
|
|
def test_get_ws_url_forwarded_port(self, server):
|
|
"""Test WebSocket URL with forwarded port."""
|
|
request = MagicMock()
|
|
request.headers = {
|
|
"Host": "localhost:8080",
|
|
"X-Forwarded-Host": "example.com",
|
|
"X-Forwarded-Port": "9000",
|
|
}
|
|
request.secure = False
|
|
|
|
url = server._get_ws_url_from_request(request, "test-route")
|
|
assert "9000" in url
|
|
|
|
def test_get_ws_url_standard_port_omitted(self, server):
|
|
"""Test that standard ports are omitted from URL."""
|
|
request = MagicMock()
|
|
request.headers = {
|
|
"Host": "example.com",
|
|
"X-Forwarded-Port": "443",
|
|
"X-Forwarded-Proto": "https",
|
|
}
|
|
request.secure = True
|
|
|
|
url = server._get_ws_url_from_request(request, "test-route")
|
|
# Port 443 should be omitted
|
|
assert ":443" not in url or url == "wss://example.com/ws/test-route"
|
|
|
|
|
|
class TestWebSocketProtocol:
|
|
"""Tests for WebSocket protocol message formats."""
|
|
|
|
def test_stdin_message_format(self):
|
|
"""Test stdin message format."""
|
|
msg = ["stdin", "hello"]
|
|
assert msg[0] == "stdin"
|
|
assert msg[1] == "hello"
|
|
|
|
def test_resize_message_format(self):
|
|
"""Test resize message format."""
|
|
msg = ["resize", {"width": 80, "height": 24}]
|
|
assert msg[0] == "resize"
|
|
assert msg[1]["width"] == 80
|
|
assert msg[1]["height"] == 24
|
|
|
|
def test_ping_pong_format(self):
|
|
"""Test ping/pong message format."""
|
|
ping = ["ping", "1234567890"]
|
|
pong = ["pong", "1234567890"]
|
|
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
|
|
assert "<title>Known</title>" 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_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, "<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_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 "<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")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_stops_exit_poller_and_exits_poller(self, server_with_no_apps, monkeypatch):
|
|
async def boom():
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(server_with_no_apps, "_run", boom)
|
|
server_with_no_apps._exit_poller.stop = MagicMock()
|
|
server_with_no_apps._poller.exit = MagicMock()
|
|
|
|
with pytest.raises(RuntimeError):
|
|
await server_with_no_apps.run()
|
|
|
|
server_with_no_apps._exit_poller.stop.assert_called_once()
|
|
server_with_no_apps._poller.exit.assert_called_once()
|
|
|
|
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("textual_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 pathlib import Path
|
|
|
|
from textual_webterm import local_server
|
|
|
|
class FakePath(Path):
|
|
_flavour = type(Path())._flavour
|
|
|
|
def exists(self) -> 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 "<svg" in resp.text
|
|
session.get_screen_lines.assert_awaited_once()
|