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:
chengyongru
2026-06-02 17:14:38 +08:00
committed by Xubin Ren
parent 2420826e05
commit 2a98360105
14 changed files with 753 additions and 779 deletions
+58 -33
View File
@@ -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