test: speed up CI and harden the suite

This commit is contained in:
chengyongru
2026-07-15 00:18:37 +08:00
committed by chengyongru
parent 06f47fa540
commit 2116e32013
33 changed files with 453 additions and 240 deletions
@@ -352,6 +352,11 @@ class TestProgressFiltering:
content="legacy progress-shaped message",
metadata={"_progress": True},
))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="processing sentinel",
))
task = asyncio.create_task(manager._dispatch_outbound())
try:
@@ -366,7 +371,9 @@ class TestProgressFiltering:
except asyncio.CancelledError:
pass
assert manager.channels["mock"]._send_mock.await_count == 0
send_mock = manager.channels["mock"]._send_mock
assert send_mock.await_count == 1
assert send_mock.await_args.args[0].content == "processing sentinel"
@pytest.mark.asyncio
async def test_channel_override_can_enable_tool_hints(self, manager, bus):
-2
View File
@@ -1,8 +1,6 @@
"""Tests for Feishu/Lark domain configuration."""
from unittest.mock import MagicMock
import pytest
from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig
-2
View File
@@ -2,8 +2,6 @@
from types import SimpleNamespace
import pytest
from nanobot.channels.feishu import FeishuChannel
+5 -1
View File
@@ -629,7 +629,9 @@ async def test_send_delta_stream_end_treats_not_modified_as_success() -> None:
@pytest.mark.asyncio
async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> None:
async def test_send_delta_stream_end_does_not_fallback_on_network_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""TimedOut during HTML edit should propagate, never fall back to plain text."""
from telegram.error import TimedOut
@@ -638,6 +640,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> N
MessageBus(),
)
channel._app = _FakeApp(lambda: None)
monkeypatch.setattr("nanobot.channels.telegram._SEND_RETRY_BASE_DELAY", 0)
# _call_with_retry retries TimedOut up to 3 times, so the mock will be called
# multiple times but all calls must be with parse_mode="HTML" (no plain fallback).
channel._app.bot.edit_message_text = AsyncMock(side_effect=TimedOut("network timeout"))
@@ -650,6 +653,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> N
# no plain-text fallback call should have been made.
for call in channel._app.bot.edit_message_text.call_args_list:
assert call.kwargs.get("parse_mode") == "HTML"
assert channel._app.bot.edit_message_text.await_count == 3
# Buffer should still be present (not cleaned up on error)
assert "123" in channel._stream_bufs
+1 -9
View File
@@ -1,18 +1,17 @@
"""Unit and lightweight integration tests for the WebSocket channel."""
import asyncio
import functools
import json
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import websockets
from websockets.exceptions import ConnectionClosed
from websockets.frames import Close
from ws_test_client import http_get as _http_get
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.outbound_events import (
@@ -169,13 +168,6 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
)
async def _http_get(url: str, headers: dict[str, str] | None = None) -> httpx.Response:
"""Run GET in a thread to avoid blocking the asyncio loop shared with websockets."""
return await asyncio.to_thread(
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
)
@pytest.mark.asyncio
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
class Conn:
+3 -39
View File
@@ -1,7 +1,6 @@
"""End-to-end tests for the embedded webui's HTTP routes on the WebSocket channel."""
import asyncio
import functools
import json
import random
import socket
@@ -12,8 +11,9 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock
from urllib.parse import quote, urlencode
import httpx
import pytest
from ws_test_client import InProcessHttpChannel
from ws_test_client import http_get as _http_get
from nanobot.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel
@@ -134,7 +134,7 @@ def _ch(
local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
return InProcessHttpChannel(cfg, bus, gateway=gateway)
@pytest.fixture()
@@ -144,14 +144,6 @@ def bus() -> MagicMock:
return b
async def _http_get(
url: str, headers: dict[str, str] | None = None
) -> httpx.Response:
return await asyncio.to_thread(
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
)
def _seed_session(workspace: Path, key: str = "websocket:test") -> SessionManager:
sm = SessionManager(workspace)
s = Session(key=key)
@@ -211,7 +203,6 @@ async def test_bootstrap_returns_token_for_localhost(
maxMessageBytes=1_048_576,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
assert resp.status_code == 200
@@ -248,7 +239,6 @@ async def test_sessions_routes_require_bearer_token(
sm = _seed_session(tmp_path, key="websocket:abc")
channel = _ch(bus, session_manager=sm, port=29902)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
# Unauthenticated → 401.
deny = await _http_get("http://127.0.0.1:29902/api/sessions")
@@ -324,7 +314,6 @@ async def test_session_automations_route_filters_by_webui_session(
port=29914,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket:abc/automations"
@@ -381,7 +370,6 @@ async def test_session_automations_route_ignores_unified_owner(
port=29917,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -427,7 +415,6 @@ async def test_session_automations_route_lists_local_triggers(
port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -487,7 +474,6 @@ async def test_webui_skills_route_requires_token_and_hides_paths(
port=29920,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29920/api/webui/skills")
assert deny.status_code == 401
@@ -585,7 +571,6 @@ async def test_cli_apps_routes_require_token_and_return_payload(
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
assert deny.status_code == 401
@@ -621,7 +606,6 @@ async def test_nanobot_feature_routes_require_token_and_enable(
_stub_matrix_feature(monkeypatch, config_path, channels=["matrix", "websocket"])
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29916)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features")
assert deny.status_code == 401
@@ -1517,7 +1501,6 @@ async def test_cli_apps_catalog_does_not_block_other_webui_http_routes(
monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", slow_payload)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29935)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -1558,7 +1541,6 @@ async def test_cli_apps_route_supports_installed_only_payload(
monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", payload)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29936)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -1651,7 +1633,6 @@ async def test_mcp_presets_routes_require_token_and_return_payload(
)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
assert deny.status_code == 401
@@ -1743,7 +1724,6 @@ async def test_sessions_list_only_returns_websocket_sessions_by_default(
)
channel = _ch(bus, session_manager=sm, port=29906)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -1769,7 +1749,6 @@ async def test_webui_sidebar_state_routes_are_config_dir_scoped(
sm = _seed_session(tmp_path, key="websocket:sidebar")
channel = _ch(bus, session_manager=sm, port=29911)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -1820,7 +1799,6 @@ async def test_session_delete_removes_file(
append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
channel = _ch(bus, session_manager=sm, port=29903)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -1900,7 +1878,6 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
deny = await _http_get(f"{base_url}/api/webui/automations")
assert deny.status_code == 401, deny.text
@@ -2115,7 +2092,6 @@ async def test_webui_automations_route_manages_local_triggers(
port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2193,7 +2169,6 @@ async def test_session_delete_blocks_when_bound_automation_exists(
)
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2238,7 +2213,6 @@ async def test_session_delete_blocks_and_cascades_local_triggers(
port=port,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2287,7 +2261,6 @@ async def test_session_delete_can_cascade_bound_automations(
)
channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2330,7 +2303,6 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled(
port=29918,
)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2362,7 +2334,6 @@ async def test_session_routes_accept_percent_encoded_websocket_keys(
sm = _seed_session(tmp_path, key="websocket:encoded-key")
channel = _ch(bus, session_manager=sm, port=29910)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2406,7 +2377,6 @@ async def test_session_messages_hide_persisted_runtime_context(
sm.save(session)
channel = _ch(bus, session_manager=sm, port=29919)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
response = await _http_get(
@@ -2459,7 +2429,6 @@ async def test_webui_thread_resigns_assistant_media_urls(
channel = _ch(bus, port=29914)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2497,7 +2466,6 @@ async def test_session_routes_reject_non_websocket_keys(
)
channel = _ch(bus, session_manager=sm, port=29909)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2530,7 +2498,6 @@ async def test_session_routes_reject_invalid_key(
sm = _seed_session(tmp_path)
channel = _ch(bus, session_manager=sm, port=29904)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -2558,7 +2525,6 @@ async def test_static_serves_index_when_dist_present(
sm = _seed_session(tmp_path / "ws_state")
channel = _ch(bus, session_manager=sm, static_dist_path=dist, port=29905)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
# Bare ``GET /`` is a browser opening the app: it must return the SPA
# index.html, not the WS-upgrade handler's 401/426.
@@ -2588,7 +2554,6 @@ async def test_static_rejects_path_traversal(
secret.write_text("classified")
channel = _ch(bus, static_dist_path=dist, port=29906)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get("http://127.0.0.1:29906/../secret.txt")
# Normalized by httpx into /secret.txt → falls back to index.html, not 'classified'.
@@ -2602,7 +2567,6 @@ async def test_static_rejects_path_traversal(
async def test_unknown_route_returns_404(bus: MagicMock) -> None:
channel = _ch(bus, port=29907)
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get("http://127.0.0.1:29907/api/unknown")
assert resp.status_code == 404
@@ -60,7 +60,6 @@ def bus() -> MagicMock:
async def test_ready_event_fields(bus: MagicMock) -> None:
ch = _ch(bus, 29901)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29901/", client_id="c1") as c:
r = await c.recv_ready()
@@ -76,7 +75,6 @@ async def test_ready_event_fields(bus: MagicMock) -> None:
async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
ch = _ch(bus, 29902)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29902/", client_id="") as c:
r = await c.recv_ready()
@@ -90,7 +88,6 @@ async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
ch = _ch(bus, 29903)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29903/", client_id="a") as c1:
async with WsTestClient("ws://127.0.0.1:29903/", client_id="b") as c2:
@@ -107,7 +104,6 @@ async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
async def test_plain_text(bus: MagicMock) -> None:
ch = _ch(bus, 29904)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29904/", client_id="p") as c:
await c.recv_ready()
@@ -125,7 +121,6 @@ async def test_plain_text(bus: MagicMock) -> None:
async def test_json_content_field(bus: MagicMock) -> None:
ch = _ch(bus, 29905)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29905/", client_id="j") as c:
await c.recv_ready()
@@ -141,7 +136,6 @@ async def test_json_content_field(bus: MagicMock) -> None:
async def test_json_text_and_message_fields(bus: MagicMock) -> None:
ch = _ch(bus, 29906)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29906/", client_id="x") as c:
await c.recv_ready()
@@ -160,7 +154,6 @@ async def test_json_text_and_message_fields(bus: MagicMock) -> None:
async def test_empty_payload_ignored(bus: MagicMock) -> None:
ch = _ch(bus, 29907)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29907/", client_id="e") as c:
await c.recv_ready()
@@ -177,7 +170,6 @@ async def test_empty_payload_ignored(bus: MagicMock) -> None:
async def test_messages_preserve_order(bus: MagicMock) -> None:
ch = _ch(bus, 29908)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29908/", client_id="o") as c:
await c.recv_ready()
@@ -198,7 +190,6 @@ async def test_messages_preserve_order(bus: MagicMock) -> None:
async def test_server_send_message(bus: MagicMock) -> None:
ch = _ch(bus, 29909)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29909/", client_id="r") as c:
ready = await c.recv_ready()
@@ -217,7 +208,6 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
"""Tool-hint progress events surface as ``kind: "tool_hint"``."""
ch = _ch(bus, 29919)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c:
ready = await c.recv_ready()
@@ -255,7 +245,6 @@ async def test_server_send_tags_tool_hint_with_kind(bus: MagicMock) -> None:
async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
ch = _ch(bus, 29910)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29910/", client_id="m") as c:
ready = await c.recv_ready()
@@ -279,7 +268,6 @@ async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
ch = _ch(bus, 29911, streaming=True)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c:
cid = (await c.recv_ready()).chat_id
@@ -301,7 +289,6 @@ async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
async def test_interleaved_streams(bus: MagicMock) -> None:
ch = _ch(bus, 29912, streaming=True)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c:
cid = (await c.recv_ready()).chat_id
@@ -329,7 +316,6 @@ async def test_interleaved_streams(bus: MagicMock) -> None:
async def test_independent_sessions(bus: MagicMock) -> None:
ch = _ch(bus, 29913)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29913/", client_id="u1") as c1:
async with WsTestClient("ws://127.0.0.1:29913/", client_id="u2") as c2:
@@ -351,7 +337,6 @@ async def test_independent_sessions(bus: MagicMock) -> None:
async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
ch = _ch(bus, 29914)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c:
chat_id = (await c.recv_ready()).chat_id
@@ -373,7 +358,6 @@ async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
async def test_static_token_accepted(bus: MagicMock) -> None:
ch = _ch(bus, 29915, token="secret")
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29915/", client_id="a", token="secret") as c:
assert (await c.recv_ready()).client_id == "a"
@@ -386,7 +370,6 @@ async def test_static_token_accepted(bus: MagicMock) -> None:
async def test_static_token_rejected(bus: MagicMock) -> None:
ch = _ch(bus, 29916, token="correct")
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29916/", client_id="b", token="wrong"):
@@ -403,7 +386,6 @@ async def test_token_issue_full_flow(bus: MagicMock) -> None:
tokenIssuePath="/auth/token", tokenIssueSecret="s",
websocketRequiresToken=True)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
# no secret -> 401
_, status = await issue_token(port=29917, issue_path="/auth/token")
@@ -439,7 +421,6 @@ async def test_token_issue_full_flow(bus: MagicMock) -> None:
async def test_custom_path(bus: MagicMock) -> None:
ch = _ch(bus, 29918, path="/my-chat")
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29918/my-chat", client_id="p") as c:
assert (await c.recv_ready()).event == "ready"
@@ -452,7 +433,6 @@ async def test_custom_path(bus: MagicMock) -> None:
async def test_wrong_path_404(bus: MagicMock) -> None:
ch = _ch(bus, 29919, path="/ws")
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29919/wrong", client_id="x"):
@@ -467,7 +447,6 @@ async def test_wrong_path_404(bus: MagicMock) -> None:
async def test_trailing_slash_normalized(bus: MagicMock) -> None:
ch = _ch(bus, 29920, path="/ws")
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29920/ws/", client_id="s") as c:
assert (await c.recv_ready()).event == "ready"
@@ -483,7 +462,6 @@ async def test_trailing_slash_normalized(bus: MagicMock) -> None:
async def test_large_message(bus: MagicMock) -> None:
ch = _ch(bus, 29921)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29921/", client_id="big") as c:
await c.recv_ready()
@@ -500,7 +478,6 @@ async def test_large_message(bus: MagicMock) -> None:
async def test_unicode_roundtrip(bus: MagicMock) -> None:
ch = _ch(bus, 29922)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29922/", client_id="u") as c:
ready = await c.recv_ready()
@@ -521,7 +498,6 @@ async def test_unicode_roundtrip(bus: MagicMock) -> None:
async def test_rapid_fire(bus: MagicMock) -> None:
ch = _ch(bus, 29923)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29923/", client_id="r") as c:
ready = await c.recv_ready()
@@ -544,7 +520,6 @@ async def test_rapid_fire(bus: MagicMock) -> None:
async def test_invalid_json_as_plain_text(bus: MagicMock) -> None:
ch = _ch(bus, 29924)
t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try:
async with WsTestClient("ws://127.0.0.1:29924/", client_id="j") as c:
await c.recv_ready()
+3 -22
View File
@@ -11,15 +11,15 @@ These tests cover the two halves end-to-end plus the adversarial edges
from __future__ import annotations
import asyncio
import functools
import hashlib
import hmac
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from ws_test_client import InProcessHttpChannel
from ws_test_client import http_get as _http_get
from nanobot.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.session.manager import Session, SessionManager
@@ -67,7 +67,7 @@ def _ch(
runtime_surface="browser",
runtime_capabilities_overrides=None,
)
return WebSocketChannel(cfg, bus, gateway=gateway)
return InProcessHttpChannel(cfg, bus, gateway=gateway)
@pytest.fixture()
@@ -86,14 +86,6 @@ def _fake_media_dir(root: Path):
return inner
async def _http_get(
url: str, headers: dict[str, str] | None = None
) -> httpx.Response:
return await asyncio.to_thread(
functools.partial(httpx.get, url, headers=headers or {}, timeout=5.0, trust_env=False)
)
# ---------------------------------------------------------------------------
# gateway.media.sign_media_path: the URL minter
# ---------------------------------------------------------------------------
@@ -223,7 +215,6 @@ async def test_media_route_serves_signed_file(
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(f"http://127.0.0.1:29920{url_path}")
finally:
@@ -256,7 +247,6 @@ async def test_media_route_serves_video_byte_ranges(
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(
f"http://127.0.0.1:29927{url_path}",
@@ -288,7 +278,6 @@ async def test_media_route_serves_suffix_video_byte_ranges(
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(
f"http://127.0.0.1:29928{url_path}",
@@ -317,7 +306,6 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(
f"http://127.0.0.1:29929{url_path}",
@@ -357,7 +345,6 @@ async def test_media_route_rejects_bad_signature(
forged = f"/api/media/{b64url_encode(forged_mac)}/{payload}"
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(f"http://127.0.0.1:29921{forged}")
finally:
@@ -391,7 +378,6 @@ async def test_media_route_rejects_path_traversal_payload(
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(f"http://127.0.0.1:29922{url}")
finally:
@@ -418,7 +404,6 @@ async def test_media_route_404s_missing_file(
assert url_path is not None
target.unlink() # the file vanishes between signing and fetching
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(f"http://127.0.0.1:29923{url_path}")
finally:
@@ -448,7 +433,6 @@ async def test_media_route_degrades_non_image_to_octet_stream(
).digest()[:16]
url = f"/api/media/{b64url_encode(mac)}/{payload}"
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(f"http://127.0.0.1:29924{url}")
finally:
@@ -476,7 +460,6 @@ async def test_media_route_serves_svg_with_strict_csp(
url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
resp = await _http_get(f"http://127.0.0.1:29928{url_path}")
finally:
@@ -515,7 +498,6 @@ async def test_session_messages_exposes_signed_media_urls(
channel = _ch(bus, session_manager=sm, port=29925)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"}
@@ -559,7 +541,6 @@ async def test_session_messages_skips_vanished_media(
channel = _ch(bus, session_manager=sm, port=29926)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try:
token = channel.gateway.tokens.issue_api_token(300)
resp = await _http_get(
+38 -7
View File
@@ -37,6 +37,23 @@ def _make_channel() -> tuple[WeixinChannel, MessageBus]:
return channel, bus
@pytest.fixture
def no_qr_poll_delay(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep QR state-machine tests event-driven without one-second polling sleeps."""
real_sleep = asyncio.sleep
async def yield_to_loop(_delay: float) -> None:
await real_sleep(0)
class AsyncioProxy:
sleep = staticmethod(yield_to_loop)
def __getattr__(self, name: str):
return getattr(asyncio, name)
monkeypatch.setattr(weixin_mod, "asyncio", AsyncioProxy())
def test_make_headers_includes_route_tag_when_configured() -> None:
bus = MessageBus()
channel = WeixinChannel(
@@ -446,7 +463,9 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
@pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None:
async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
@@ -478,7 +497,9 @@ async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None:
@pytest.mark.asyncio
async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None:
async def test_qr_login_returns_false_after_too_many_expired_qr_codes(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._print_qr_code = lambda url: None
@@ -505,7 +526,9 @@ async def test_qr_login_returns_false_after_too_many_expired_qr_codes() -> None:
@pytest.mark.asyncio
async def test_qr_login_switches_polling_base_url_on_redirect_status() -> None:
async def test_qr_login_switches_polling_base_url_on_redirect_status(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
@@ -537,7 +560,9 @@ async def test_qr_login_switches_polling_base_url_on_redirect_status() -> None:
@pytest.mark.asyncio
async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() -> None:
async def test_qr_login_redirect_without_host_keeps_current_polling_base_url(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
@@ -569,7 +594,9 @@ async def test_qr_login_redirect_without_host_keeps_current_polling_base_url() -
@pytest.mark.asyncio
async def test_qr_login_resets_redirect_base_url_after_qr_refresh() -> None:
async def test_qr_login_resets_redirect_base_url_after_qr_refresh(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
@@ -859,7 +886,9 @@ async def test_get_typing_ticket_failure_uses_backoff_and_cached_ticket(monkeypa
@pytest.mark.asyncio
async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers() -> None:
async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
@@ -887,7 +916,9 @@ async def test_qr_login_treats_temporary_connect_error_as_wait_and_recovers() ->
@pytest.mark.asyncio
async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers() -> None:
async def test_qr_login_treats_5xx_gateway_response_error_as_wait_and_recovers(
no_qr_poll_delay,
) -> None:
channel, _bus = _make_channel()
channel._running = True
channel._save_state = lambda: None
+91 -9
View File
@@ -21,6 +21,58 @@ from typing import Any
import httpx
import websockets
from websockets.asyncio.client import ClientConnection
from websockets.datastructures import Headers
from websockets.http11 import Request as WsRequest
from nanobot.channels.websocket import WebSocketChannel
from nanobot.webui.http_utils import http_response
_IN_PROCESS_HTTP_CHANNELS: dict[int, InProcessHttpChannel] = {}
class _HttpConnection:
remote_address = ("127.0.0.1", 12345)
@staticmethod
def respond(status: int, body: str) -> object:
return http_response(body.encode("utf-8"), status=status)
class InProcessHttpChannel(WebSocketChannel):
"""Exercise gateway HTTP dispatch without booting a socket per route test."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._test_stop_event = asyncio.Event()
_IN_PROCESS_HTTP_CHANNELS[self.config.port] = self
async def start(self) -> None:
self._running = True
await self._test_stop_event.wait()
self._running = False
async def stop(self) -> None:
self._test_stop_event.set()
if _IN_PROCESS_HTTP_CHANNELS.get(self.config.port) is self:
_IN_PROCESS_HTTP_CHANNELS.pop(self.config.port, None)
async def _in_process_http_get(
channel: InProcessHttpChannel,
request: httpx.Request,
) -> httpx.Response:
ws_request = WsRequest(
request.url.raw_path.decode("ascii"),
Headers(list(request.headers.multi_items())),
)
response = await channel._dispatch_http(_HttpConnection(), ws_request)
assert response is not None
return httpx.Response(
response.status_code,
headers=list(response.headers.raw_items()),
content=response.body,
request=request,
)
@dataclass
@@ -89,11 +141,19 @@ class WsTestClient:
self._extra_headers = extra_headers
self._ws: ClientConnection | None = None
async def connect(self) -> None:
self._ws = await websockets.connect(
self._uri,
additional_headers=self._extra_headers,
)
async def connect(self, timeout: float = 2.0) -> None:
deadline = asyncio.get_running_loop().time() + timeout
while True:
try:
self._ws = await websockets.connect(
self._uri,
additional_headers=self._extra_headers,
)
return
except OSError:
if asyncio.get_running_loop().time() >= deadline:
raise
await asyncio.sleep(0.01)
async def close(self) -> None:
if self._ws:
@@ -186,6 +246,31 @@ class WsTestClient:
# -- Token issuance helpers -----------------------------------------------
async def http_get(
url: str,
headers: dict[str, str] | None = None,
) -> httpx.Response:
"""GET a local test server without loading an unused TLS trust store."""
request = httpx.Request("GET", url, headers=headers or {})
channel = _IN_PROCESS_HTTP_CHANNELS.get(request.url.port)
if channel is not None:
return await _in_process_http_get(channel, request)
deadline = asyncio.get_running_loop().time() + 2.0
while True:
try:
async with httpx.AsyncClient(
timeout=5.0,
trust_env=False,
verify=False,
) as client:
return await client.get(url, headers=headers or {})
except httpx.ConnectError:
if asyncio.get_running_loop().time() >= deadline:
raise
await asyncio.sleep(0.01)
async def issue_token(
host: str = "127.0.0.1",
port: int = 8765,
@@ -201,10 +286,7 @@ async def issue_token(
if secret:
headers["Authorization"] = f"Bearer {secret}"
loop = asyncio.get_running_loop()
resp = await loop.run_in_executor(
None, lambda: httpx.get(url, headers=headers, timeout=5.0)
)
resp = await http_get(url, headers)
try:
data = resp.json()
except Exception: