refactor: split WebUI gateway dependencies
Maintainer edit for PR 4115: rebase onto origin/main and split gateway HTTP routing from token, media, and workspace services so WebSocketChannel depends on explicit gateway services instead of GatewayHTTPHandler internals. Preserve file edit channel capabilities and restore tools.restrict_to_workspace wiring through ChannelManager.
This commit is contained in:
@@ -7,19 +7,18 @@ multi-client scenarios, edge cases, and realistic usage patterns.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import websockets
|
||||
|
||||
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||
from ws_test_client import WsTestClient, issue_token, issue_token_ok
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
|
||||
from nanobot.webui.gateway_services import build_gateway_services
|
||||
|
||||
|
||||
def _ch(bus: Any, port: int, **kw: Any) -> WebSocketChannel:
|
||||
cfg: dict[str, Any] = {
|
||||
@@ -32,17 +31,18 @@ def _ch(bus: Any, port: int, **kw: Any) -> WebSocketChannel:
|
||||
}
|
||||
cfg.update(kw)
|
||||
parsed = WebSocketConfig.model_validate(cfg)
|
||||
handler = GatewayHTTPHandler(
|
||||
gateway = build_gateway_services(
|
||||
config=parsed,
|
||||
bus=bus,
|
||||
session_manager=None,
|
||||
static_dist_path=None,
|
||||
workspace_path=Path.cwd(),
|
||||
default_restrict_to_workspace=False,
|
||||
runtime_model_name=None,
|
||||
runtime_surface="browser",
|
||||
runtime_capabilities_overrides=None,
|
||||
bus=bus,
|
||||
)
|
||||
return WebSocketChannel(cfg, bus, http_handler=handler)
|
||||
return WebSocketChannel(cfg, bus, gateway=gateway)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -67,7 +67,8 @@ async def test_ready_event_fields(bus: MagicMock) -> None:
|
||||
assert len(r.chat_id) == 36
|
||||
assert r.client_id == "c1"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -80,7 +81,8 @@ async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
|
||||
r = await c.recv_ready()
|
||||
assert r.client_id.startswith("anon-")
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -93,7 +95,8 @@ async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
|
||||
async with WsTestClient("ws://127.0.0.1:29903/", client_id="b") as c2:
|
||||
assert (await c1.recv_ready()).chat_id != (await c2.recv_ready()).chat_id
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
# -- Inbound messages (client -> server) ----------------------------------
|
||||
@@ -113,7 +116,8 @@ async def test_plain_text(bus: MagicMock) -> None:
|
||||
assert inbound.content == "hello world"
|
||||
assert inbound.sender_id == "p"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -128,7 +132,8 @@ async def test_json_content_field(bus: MagicMock) -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
assert bus.publish_inbound.call_args[0][0].content == "structured"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -146,7 +151,8 @@ async def test_json_text_and_message_fields(bus: MagicMock) -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
assert bus.publish_inbound.call_args[0][0].content == "via message"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -162,7 +168,8 @@ async def test_empty_payload_ignored(bus: MagicMock) -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
bus.publish_inbound.assert_not_awaited()
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -179,7 +186,8 @@ async def test_messages_preserve_order(bus: MagicMock) -> None:
|
||||
contents = [call[0][0].content for call in bus.publish_inbound.call_args_list]
|
||||
assert contents == [f"msg-{i}" for i in range(5)]
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
# -- Outbound messages (server -> client) ---------------------------------
|
||||
@@ -199,7 +207,8 @@ async def test_server_send_message(bus: MagicMock) -> None:
|
||||
msg = await c.recv_message()
|
||||
assert msg.text == "reply"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -238,7 +247,8 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
|
||||
prog = await c.recv_message()
|
||||
assert prog.raw.get("kind") == "progress"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -258,7 +268,8 @@ async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
|
||||
assert msg.media == ["/tmp/a.png"]
|
||||
assert msg.reply_to == "m1"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
# -- Streaming ------------------------------------------------------------
|
||||
@@ -282,7 +293,8 @@ async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
|
||||
ends = [m for m in msgs if m.event == "stream_end"]
|
||||
assert len(ends) == 1
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -306,7 +318,8 @@ async def test_interleaved_streams(bus: MagicMock) -> None:
|
||||
assert sa == "A1A2"
|
||||
assert sb == "B1B2"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
# -- Multi-client ---------------------------------------------------------
|
||||
@@ -330,7 +343,8 @@ async def test_independent_sessions(bus: MagicMock) -> None:
|
||||
))
|
||||
assert (await c2.recv_message()).text == "for-u2"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -348,7 +362,8 @@ async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
|
||||
))
|
||||
assert chat_id not in ch._subs
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
# -- Authentication -------------------------------------------------------
|
||||
@@ -363,7 +378,8 @@ async def test_static_token_accepted(bus: MagicMock) -> None:
|
||||
async with WsTestClient("ws://127.0.0.1:29915/", client_id="a", token="secret") as c:
|
||||
assert (await c.recv_ready()).client_id == "a"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -377,7 +393,8 @@ async def test_static_token_rejected(bus: MagicMock) -> None:
|
||||
pass
|
||||
assert exc.value.response.status_code == 401
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -411,7 +428,8 @@ async def test_token_issue_full_flow(bus: MagicMock) -> None:
|
||||
pass
|
||||
assert exc.value.response.status_code == 401
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
# -- Path routing ---------------------------------------------------------
|
||||
@@ -426,7 +444,8 @@ async def test_custom_path(bus: MagicMock) -> None:
|
||||
async with WsTestClient("ws://127.0.0.1:29918/my-chat", client_id="p") as c:
|
||||
assert (await c.recv_ready()).event == "ready"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -440,7 +459,8 @@ async def test_wrong_path_404(bus: MagicMock) -> None:
|
||||
pass
|
||||
assert exc.value.response.status_code == 404
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -452,7 +472,8 @@ async def test_trailing_slash_normalized(bus: MagicMock) -> None:
|
||||
async with WsTestClient("ws://127.0.0.1:29920/ws/", client_id="s") as c:
|
||||
assert (await c.recv_ready()).event == "ready"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
# -- Edge cases -----------------------------------------------------------
|
||||
@@ -471,7 +492,8 @@ async def test_large_message(bus: MagicMock) -> None:
|
||||
await asyncio.sleep(0.2)
|
||||
assert bus.publish_inbound.call_args[0][0].content == big
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -491,7 +513,8 @@ async def test_unicode_roundtrip(bus: MagicMock) -> None:
|
||||
))
|
||||
assert (await c.recv_message()).text == text
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -513,7 +536,8 @@ async def test_rapid_fire(bus: MagicMock) -> None:
|
||||
received = [(await c.recv_message()).text for _ in range(50)]
|
||||
assert received == [f"out-{i}" for i in range(50)]
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -528,4 +552,5 @@ async def test_invalid_json_as_plain_text(bus: MagicMock) -> None:
|
||||
await asyncio.sleep(0.1)
|
||||
assert bus.publish_inbound.call_args[0][0].content == "{broken json"
|
||||
finally:
|
||||
await ch.stop(); await t
|
||||
await ch.stop()
|
||||
await t
|
||||
|
||||
Reference in New Issue
Block a user