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
+28 -6
View File
@@ -19,14 +19,25 @@ permissions:
jobs: jobs:
test: test:
name: Python (${{ matrix.name }})
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }} include:
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python). - name: minimum, 3.11
python-version: ${{ fromJSON('["3.13","3.14"]') }} os: ubuntu-latest
python-version: "3.11"
coverage: false
- name: latest, 3.14 + coverage
os: ubuntu-latest
python-version: "3.14"
coverage: true
- name: Windows, 3.14
os: windows-latest
python-version: "3.14"
coverage: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -47,10 +58,21 @@ jobs:
run: uv sync --all-extras --dev run: uv sync --all-extras --dev
- name: Lint with ruff - name: Lint with ruff
run: uv run ruff check nanobot --select F if: matrix.coverage
run: uv run ruff check nanobot tests
- name: Run tests - name: Run tests with coverage
run: uv run python -m pytest tests/ --cov=nanobot --cov-report=term-missing:skip-covered if: matrix.coverage
run: >-
uv run python -m pytest tests/
--cov=nanobot --cov-report=term-missing:skip-covered
--durations=25 --durations-min=1.0
- name: Run compatibility tests
if: ${{ !matrix.coverage }}
run: >-
uv run python -m pytest tests/
--durations=25 --durations-min=1.0
webui: webui:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+38 -9
View File
@@ -41,6 +41,26 @@ __all__ = (
API_SESSION_KEY = "api:default" API_SESSION_KEY = "api:default"
API_CHAT_ID = "default" API_CHAT_ID = "default"
_AGENT_LOOP_KEY = web.AppKey[Any]("agent_loop")
_MODEL_NAME_KEY = web.AppKey[str]("model_name")
_REQUEST_TIMEOUT_KEY = web.AppKey[float]("request_timeout")
_SESSION_LOCKS_KEY = web.AppKey[dict]("session_locks")
_MISSING = object()
def _app_value(
app: Any,
key: web.AppKey[Any],
legacy_key: str,
default: Any = _MISSING,
) -> Any:
"""Read typed aiohttp state while accepting lightweight dict test doubles."""
try:
return app[key]
except KeyError:
if default is _MISSING:
return app[legacy_key]
return app.get(legacy_key, default)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -209,9 +229,14 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
if not isinstance(content_type, str): if not isinstance(content_type, str):
content_type = "" content_type = ""
agent_loop = request.app["agent_loop"] agent_loop = _app_value(request.app, _AGENT_LOOP_KEY, "agent_loop")
timeout_s: float = request.app.get("request_timeout", 120.0) timeout_s: float = _app_value(
model_name: str = request.app.get("model_name", "nanobot") request.app,
_REQUEST_TIMEOUT_KEY,
"request_timeout",
120.0,
)
model_name: str = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
stream = False stream = False
try: try:
@@ -238,7 +263,11 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
return _error_json(400, f"Only configured model '{model_name}' is available") return _error_json(400, f"Only configured model '{model_name}' is available")
session_key = f"api:{session_id}" if session_id else API_SESSION_KEY session_key = f"api:{session_id}" if session_id else API_SESSION_KEY
session_locks: dict[str, asyncio.Lock] = request.app["session_locks"] session_locks: dict[str, asyncio.Lock] = _app_value(
request.app,
_SESSION_LOCKS_KEY,
"session_locks",
)
session_lock = session_locks.setdefault(session_key, asyncio.Lock()) session_lock = session_locks.setdefault(session_key, asyncio.Lock())
logger.info( logger.info(
@@ -366,7 +395,7 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
async def handle_models(request: web.Request) -> web.Response: async def handle_models(request: web.Request) -> web.Response:
"""GET /v1/models""" """GET /v1/models"""
model_name = request.app.get("model_name", "nanobot") model_name = _app_value(request.app, _MODEL_NAME_KEY, "model_name", "nanobot")
return web.json_response( return web.json_response(
{ {
"object": "list", "object": "list",
@@ -407,10 +436,10 @@ def create_app(
api_key: Optional API key for Bearer-token authentication on API routes. api_key: Optional API key for Bearer-token authentication on API routes.
""" """
app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images app = web.Application(client_max_size=20 * 1024 * 1024) # 20MB for base64 images
app["agent_loop"] = agent_loop app[_AGENT_LOOP_KEY] = agent_loop
app["model_name"] = model_name app[_MODEL_NAME_KEY] = model_name
app["request_timeout"] = request_timeout app[_REQUEST_TIMEOUT_KEY] = request_timeout
app["session_locks"] = {} # per-user locks, keyed by session_key app[_SESSION_LOCKS_KEY] = {} # per-user locks, keyed by session_key
@web.middleware @web.middleware
async def auth_middleware(request: web.Request, handler) -> web.StreamResponse: async def auth_middleware(request: web.Request, handler) -> web.StreamResponse:
+2 -2
View File
@@ -1,7 +1,7 @@
from nanobot.config.schema import Config
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.context import ToolContext from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.config.schema import Config
def test_tool_loader_scope_memory_only_returns_memory_tools(): def test_tool_loader_scope_memory_only_returns_memory_tools():
+7 -4
View File
@@ -1,10 +1,9 @@
"""Tests for GitStore — git-backed version control for memory files.""" """Tests for GitStore — git-backed version control for memory files."""
import pytest import pytest
from pathlib import Path
from nanobot.utils.gitstore import GitStore, CommitInfo
from nanobot.utils.gitstore import CommitInfo, GitStore
TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"] TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"]
@@ -64,7 +63,11 @@ class TestBuildGitignore:
content = gs._build_gitignore() content = gs._build_gitignore()
assert "!a.md\n" in content assert "!a.md\n" in content
assert "!b.md\n" in content assert "!b.md\n" in content
dir_lines = [l for l in content.split("\n") if l.startswith("!") and l.endswith("/")] dir_lines = [
line
for line in content.split("\n")
if line.startswith("!") and line.endswith("/")
]
assert dir_lines == [] assert dir_lines == []
@@ -565,6 +565,7 @@ async def test_subagent_max_iterations_announces_existing_fallback(tmp_path, mon
workspace=tmp_path, workspace=tmp_path,
bus=bus, bus=bus,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
max_iterations=2,
) )
mgr._announce_result = AsyncMock() mgr._announce_result = AsyncMock()
+4 -3
View File
@@ -27,7 +27,8 @@ from nanobot.bus.queue import MessageBus
from nanobot.config.schema import MCPServerConfig from nanobot.config.schema import MCPServerConfig
from nanobot.security import network as security_network from nanobot.security import network as security_network
_IDLE_TIMEOUT_SECONDS = 5 _IDLE_TIMEOUT_SECONDS = 0.25
_IDLE_EXPIRY_GRACE_SECONDS = 0.25
_TOOL_TIMEOUT_SECONDS = 10 _TOOL_TIMEOUT_SECONDS = 10
@@ -179,7 +180,7 @@ async def test_mcp_reconnect_after_session_timeout(tmp_path, mcp_server_url):
assert "Hello, first" in output assert "Hello, first" in output
# Wait for the server-side idle timeout to terminate the session. # Wait for the server-side idle timeout to terminate the session.
await asyncio.sleep(_IDLE_TIMEOUT_SECONDS + 1) await asyncio.sleep(_IDLE_TIMEOUT_SECONDS + _IDLE_EXPIRY_GRACE_SECONDS)
output = await asyncio.create_task(tool.execute(name="second")) output = await asyncio.create_task(tool.execute(name="second"))
assert "Hello, second" in output assert "Hello, second" in output
@@ -207,7 +208,7 @@ async def test_mcp_reconnect_during_shutdown_does_not_crash(
assert isinstance(tool, MCPToolWrapper) assert isinstance(tool, MCPToolWrapper)
await asyncio.create_task(tool.execute(name="first")) await asyncio.create_task(tool.execute(name="first"))
await asyncio.sleep(_IDLE_TIMEOUT_SECONDS + 1) await asyncio.sleep(_IDLE_TIMEOUT_SECONDS + _IDLE_EXPIRY_GRACE_SECONDS)
reconnect_started = asyncio.Event() reconnect_started = asyncio.Event()
finish_reconnect = asyncio.Event() finish_reconnect = asyncio.Event()
@@ -1,10 +1,8 @@
import importlib import importlib
import shutil
import sys import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
SCRIPT_DIR = Path("nanobot/skills/skill-creator/scripts").resolve() SCRIPT_DIR = Path("nanobot/skills/skill-creator/scripts").resolve()
if str(SCRIPT_DIR) not in sys.path: if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR)) sys.path.insert(0, str(SCRIPT_DIR))
+5 -7
View File
@@ -12,14 +12,12 @@ from __future__ import annotations
import asyncio import asyncio
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock, patch, AsyncMock
import pytest import pytest
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
def _make_provider(): def _make_provider():
@@ -38,8 +36,8 @@ def _make_loop(tmp_path: Path) -> AgentLoop:
provider = _make_provider() provider = _make_provider()
with patch("nanobot.agent.loop.ContextBuilder"), \ with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \ patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: patch("nanobot.agent.loop.SubagentManager") as mock_subagent_manager:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) mock_subagent_manager.return_value.cancel_by_session = AsyncMock(return_value=0)
return AgentLoop(bus=bus, provider=provider, workspace=tmp_path) return AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
@@ -110,8 +108,8 @@ async def test_dispatch_cancellation_restores_checkpoint():
with patch("nanobot.agent.loop.ContextBuilder"), \ with patch("nanobot.agent.loop.ContextBuilder"), \
patch("nanobot.agent.loop.SessionManager"), \ patch("nanobot.agent.loop.SessionManager"), \
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr: patch("nanobot.agent.loop.SubagentManager") as mock_subagent_manager:
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0) mock_subagent_manager.return_value.cancel_by_session = AsyncMock(return_value=0)
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) loop = AgentLoop(bus=bus, provider=provider, workspace=workspace)
checkpoint_key = loop._RUNTIME_CHECKPOINT_KEY checkpoint_key = loop._RUNTIME_CHECKPOINT_KEY
@@ -352,6 +352,11 @@ class TestProgressFiltering:
content="legacy progress-shaped message", content="legacy progress-shaped message",
metadata={"_progress": True}, metadata={"_progress": True},
)) ))
await bus.publish_outbound(OutboundMessage(
channel="mock",
chat_id="chat1",
content="processing sentinel",
))
task = asyncio.create_task(manager._dispatch_outbound()) task = asyncio.create_task(manager._dispatch_outbound())
try: try:
@@ -366,7 +371,9 @@ class TestProgressFiltering:
except asyncio.CancelledError: except asyncio.CancelledError:
pass 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 @pytest.mark.asyncio
async def test_channel_override_can_enable_tool_hints(self, manager, bus): 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.""" """Tests for Feishu/Lark domain configuration."""
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.channels.feishu import FeishuChannel, FeishuConfig from nanobot.channels.feishu import FeishuChannel, FeishuConfig
-2
View File
@@ -2,8 +2,6 @@
from types import SimpleNamespace from types import SimpleNamespace
import pytest
from nanobot.channels.feishu import FeishuChannel 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 @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.""" """TimedOut during HTML edit should propagate, never fall back to plain text."""
from telegram.error import TimedOut from telegram.error import TimedOut
@@ -638,6 +640,7 @@ async def test_send_delta_stream_end_does_not_fallback_on_network_timeout() -> N
MessageBus(), MessageBus(),
) )
channel._app = _FakeApp(lambda: None) 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 # _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). # 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")) 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. # no plain-text fallback call should have been made.
for call in channel._app.bot.edit_message_text.call_args_list: for call in channel._app.bot.edit_message_text.call_args_list:
assert call.kwargs.get("parse_mode") == "HTML" 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) # Buffer should still be present (not cleaned up on error)
assert "123" in channel._stream_bufs assert "123" in channel._stream_bufs
+1 -9
View File
@@ -1,18 +1,17 @@
"""Unit and lightweight integration tests for the WebSocket channel.""" """Unit and lightweight integration tests for the WebSocket channel."""
import asyncio import asyncio
import functools
import json import json
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest import pytest
import websockets import websockets
from websockets.exceptions import ConnectionClosed from websockets.exceptions import ConnectionClosed
from websockets.frames import Close 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.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.outbound_events import ( 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 @pytest.mark.asyncio
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None: async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
class Conn: 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.""" """End-to-end tests for the embedded webui's HTTP routes on the WebSocket channel."""
import asyncio import asyncio
import functools
import json import json
import random import random
import socket import socket
@@ -12,8 +11,9 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from urllib.parse import quote, urlencode from urllib.parse import quote, urlencode
import httpx
import pytest 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.bus.events import OutboundMessage
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
@@ -134,7 +134,7 @@ def _ch(
local_trigger_pending_ids=local_trigger_pending_ids, local_trigger_pending_ids=local_trigger_pending_ids,
channel_feature_action=channel_feature_action, channel_feature_action=channel_feature_action,
) )
return WebSocketChannel(cfg, bus, gateway=gateway) return InProcessHttpChannel(cfg, bus, gateway=gateway)
@pytest.fixture() @pytest.fixture()
@@ -144,14 +144,6 @@ def bus() -> MagicMock:
return b 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: def _seed_session(workspace: Path, key: str = "websocket:test") -> SessionManager:
sm = SessionManager(workspace) sm = SessionManager(workspace)
s = Session(key=key) s = Session(key=key)
@@ -211,7 +203,6 @@ async def test_bootstrap_returns_token_for_localhost(
maxMessageBytes=1_048_576, maxMessageBytes=1_048_576,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap") resp = await _http_get("http://127.0.0.1:29901/webui/bootstrap")
assert resp.status_code == 200 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") sm = _seed_session(tmp_path, key="websocket:abc")
channel = _ch(bus, session_manager=sm, port=29902) channel = _ch(bus, session_manager=sm, port=29902)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
# Unauthenticated → 401. # Unauthenticated → 401.
deny = await _http_get("http://127.0.0.1:29902/api/sessions") 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, port=29914,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
deny = await _http_get( deny = await _http_get(
"http://127.0.0.1:29914/api/sessions/websocket:abc/automations" "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, port=29917,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@@ -427,7 +415,6 @@ async def test_session_automations_route_lists_local_triggers(
port=port, port=port,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@@ -487,7 +474,6 @@ async def test_webui_skills_route_requires_token_and_hides_paths(
port=29920, port=29920,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
deny = await _http_get("http://127.0.0.1:29920/api/webui/skills") deny = await _http_get("http://127.0.0.1:29920/api/webui/skills")
assert deny.status_code == 401 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) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29912)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps") deny = await _http_get("http://127.0.0.1:29912/api/settings/cli-apps")
assert deny.status_code == 401 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"]) _stub_matrix_feature(monkeypatch, config_path, channels=["matrix", "websocket"])
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29916) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29916)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features") deny = await _http_get("http://127.0.0.1:29916/api/settings/nanobot-features")
assert deny.status_code == 401 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) monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", slow_payload)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29935) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29935)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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) monkeypatch.setattr("nanobot.webui.settings_routes.cli_apps_payload", payload)
channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29936) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29936)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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) channel = _ch(bus, session_manager=_seed_session(tmp_path), port=29913)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets") deny = await _http_get("http://127.0.0.1:29913/api/settings/mcp-presets")
assert deny.status_code == 401 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) channel = _ch(bus, session_manager=sm, port=29906)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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") sm = _seed_session(tmp_path, key="websocket:sidebar")
channel = _ch(bus, session_manager=sm, port=29911) channel = _ch(bus, session_manager=sm, port=29911)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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"}) append_transcript_object("websocket:doomed", {"event": "user", "chat_id": "doomed", "text": "x"})
channel = _ch(bus, session_manager=sm, port=29903) channel = _ch(bus, session_manager=sm, port=29903)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@@ -1900,7 +1878,6 @@ async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
port=port, port=port,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
deny = await _http_get(f"{base_url}/api/webui/automations") deny = await _http_get(f"{base_url}/api/webui/automations")
assert deny.status_code == 401, deny.text assert deny.status_code == 401, deny.text
@@ -2115,7 +2092,6 @@ async def test_webui_automations_route_manages_local_triggers(
port=port, port=port,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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) channel = _ch(bus, session_manager=sm, cron_service=cron, port=29915)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@@ -2238,7 +2213,6 @@ async def test_session_delete_blocks_and_cascades_local_triggers(
port=port, port=port,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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) channel = _ch(bus, session_manager=sm, cron_service=cron, port=29916)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@@ -2330,7 +2303,6 @@ async def test_session_delete_blocks_origin_automation_when_unified_enabled(
port=29918, port=29918,
) )
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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") sm = _seed_session(tmp_path, key="websocket:encoded-key")
channel = _ch(bus, session_manager=sm, port=29910) channel = _ch(bus, session_manager=sm, port=29910)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@@ -2406,7 +2377,6 @@ async def test_session_messages_hide_persisted_runtime_context(
sm.save(session) sm.save(session)
channel = _ch(bus, session_manager=sm, port=29919) channel = _ch(bus, session_manager=sm, port=29919)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
response = await _http_get( response = await _http_get(
@@ -2459,7 +2429,6 @@ async def test_webui_thread_resigns_assistant_media_urls(
channel = _ch(bus, port=29914) channel = _ch(bus, port=29914)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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) channel = _ch(bus, session_manager=sm, port=29909)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} auth = {"Authorization": f"Bearer {token}"}
@@ -2530,7 +2498,6 @@ async def test_session_routes_reject_invalid_key(
sm = _seed_session(tmp_path) sm = _seed_session(tmp_path)
channel = _ch(bus, session_manager=sm, port=29904) channel = _ch(bus, session_manager=sm, port=29904)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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") sm = _seed_session(tmp_path / "ws_state")
channel = _ch(bus, session_manager=sm, static_dist_path=dist, port=29905) channel = _ch(bus, session_manager=sm, static_dist_path=dist, port=29905)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
# Bare ``GET /`` is a browser opening the app: it must return the SPA # Bare ``GET /`` is a browser opening the app: it must return the SPA
# index.html, not the WS-upgrade handler's 401/426. # 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") secret.write_text("classified")
channel = _ch(bus, static_dist_path=dist, port=29906) channel = _ch(bus, static_dist_path=dist, port=29906)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get("http://127.0.0.1:29906/../secret.txt") 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'. # 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: async def test_unknown_route_returns_404(bus: MagicMock) -> None:
channel = _ch(bus, port=29907) channel = _ch(bus, port=29907)
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get("http://127.0.0.1:29907/api/unknown") resp = await _http_get("http://127.0.0.1:29907/api/unknown")
assert resp.status_code == 404 assert resp.status_code == 404
@@ -60,7 +60,6 @@ def bus() -> MagicMock:
async def test_ready_event_fields(bus: MagicMock) -> None: async def test_ready_event_fields(bus: MagicMock) -> None:
ch = _ch(bus, 29901) ch = _ch(bus, 29901)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29901/", client_id="c1") as c: async with WsTestClient("ws://127.0.0.1:29901/", client_id="c1") as c:
r = await c.recv_ready() 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: async def test_anonymous_client_gets_generated_id(bus: MagicMock) -> None:
ch = _ch(bus, 29902) ch = _ch(bus, 29902)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29902/", client_id="") as c: async with WsTestClient("ws://127.0.0.1:29902/", client_id="") as c:
r = await c.recv_ready() 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: async def test_each_connection_unique_chat_id(bus: MagicMock) -> None:
ch = _ch(bus, 29903) ch = _ch(bus, 29903)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: 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="a") as c1:
async with WsTestClient("ws://127.0.0.1:29903/", client_id="b") as c2: 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: async def test_plain_text(bus: MagicMock) -> None:
ch = _ch(bus, 29904) ch = _ch(bus, 29904)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29904/", client_id="p") as c: async with WsTestClient("ws://127.0.0.1:29904/", client_id="p") as c:
await c.recv_ready() 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: async def test_json_content_field(bus: MagicMock) -> None:
ch = _ch(bus, 29905) ch = _ch(bus, 29905)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29905/", client_id="j") as c: async with WsTestClient("ws://127.0.0.1:29905/", client_id="j") as c:
await c.recv_ready() 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: async def test_json_text_and_message_fields(bus: MagicMock) -> None:
ch = _ch(bus, 29906) ch = _ch(bus, 29906)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29906/", client_id="x") as c: async with WsTestClient("ws://127.0.0.1:29906/", client_id="x") as c:
await c.recv_ready() 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: async def test_empty_payload_ignored(bus: MagicMock) -> None:
ch = _ch(bus, 29907) ch = _ch(bus, 29907)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29907/", client_id="e") as c: async with WsTestClient("ws://127.0.0.1:29907/", client_id="e") as c:
await c.recv_ready() 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: async def test_messages_preserve_order(bus: MagicMock) -> None:
ch = _ch(bus, 29908) ch = _ch(bus, 29908)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29908/", client_id="o") as c: async with WsTestClient("ws://127.0.0.1:29908/", client_id="o") as c:
await c.recv_ready() 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: async def test_server_send_message(bus: MagicMock) -> None:
ch = _ch(bus, 29909) ch = _ch(bus, 29909)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29909/", client_id="r") as c: async with WsTestClient("ws://127.0.0.1:29909/", client_id="r") as c:
ready = await c.recv_ready() 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"``.""" """Tool-hint progress events surface as ``kind: "tool_hint"``."""
ch = _ch(bus, 29919) ch = _ch(bus, 29919)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c: async with WsTestClient("ws://127.0.0.1:29919/", client_id="h") as c:
ready = await c.recv_ready() 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: async def test_server_send_with_media_and_reply(bus: MagicMock) -> None:
ch = _ch(bus, 29910) ch = _ch(bus, 29910)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29910/", client_id="m") as c: async with WsTestClient("ws://127.0.0.1:29910/", client_id="m") as c:
ready = await c.recv_ready() 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: async def test_streaming_deltas_and_end(bus: MagicMock) -> None:
ch = _ch(bus, 29911, streaming=True) ch = _ch(bus, 29911, streaming=True)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c: async with WsTestClient("ws://127.0.0.1:29911/", client_id="s") as c:
cid = (await c.recv_ready()).chat_id 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: async def test_interleaved_streams(bus: MagicMock) -> None:
ch = _ch(bus, 29912, streaming=True) ch = _ch(bus, 29912, streaming=True)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c: async with WsTestClient("ws://127.0.0.1:29912/", client_id="i") as c:
cid = (await c.recv_ready()).chat_id 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: async def test_independent_sessions(bus: MagicMock) -> None:
ch = _ch(bus, 29913) ch = _ch(bus, 29913)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: 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="u1") as c1:
async with WsTestClient("ws://127.0.0.1:29913/", client_id="u2") as c2: 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: async def test_disconnected_client_cleanup(bus: MagicMock) -> None:
ch = _ch(bus, 29914) ch = _ch(bus, 29914)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c: async with WsTestClient("ws://127.0.0.1:29914/", client_id="tmp") as c:
chat_id = (await c.recv_ready()).chat_id 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: async def test_static_token_accepted(bus: MagicMock) -> None:
ch = _ch(bus, 29915, token="secret") ch = _ch(bus, 29915, token="secret")
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29915/", client_id="a", token="secret") as c: async with WsTestClient("ws://127.0.0.1:29915/", client_id="a", token="secret") as c:
assert (await c.recv_ready()).client_id == "a" 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: async def test_static_token_rejected(bus: MagicMock) -> None:
ch = _ch(bus, 29916, token="correct") ch = _ch(bus, 29916, token="correct")
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
with pytest.raises(websockets.exceptions.InvalidStatus) as exc: with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29916/", client_id="b", token="wrong"): 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", tokenIssuePath="/auth/token", tokenIssueSecret="s",
websocketRequiresToken=True) websocketRequiresToken=True)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
# no secret -> 401 # no secret -> 401
_, status = await issue_token(port=29917, issue_path="/auth/token") _, 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: async def test_custom_path(bus: MagicMock) -> None:
ch = _ch(bus, 29918, path="/my-chat") ch = _ch(bus, 29918, path="/my-chat")
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29918/my-chat", client_id="p") as c: async with WsTestClient("ws://127.0.0.1:29918/my-chat", client_id="p") as c:
assert (await c.recv_ready()).event == "ready" 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: async def test_wrong_path_404(bus: MagicMock) -> None:
ch = _ch(bus, 29919, path="/ws") ch = _ch(bus, 29919, path="/ws")
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
with pytest.raises(websockets.exceptions.InvalidStatus) as exc: with pytest.raises(websockets.exceptions.InvalidStatus) as exc:
async with WsTestClient("ws://127.0.0.1:29919/wrong", client_id="x"): 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: async def test_trailing_slash_normalized(bus: MagicMock) -> None:
ch = _ch(bus, 29920, path="/ws") ch = _ch(bus, 29920, path="/ws")
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29920/ws/", client_id="s") as c: async with WsTestClient("ws://127.0.0.1:29920/ws/", client_id="s") as c:
assert (await c.recv_ready()).event == "ready" 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: async def test_large_message(bus: MagicMock) -> None:
ch = _ch(bus, 29921) ch = _ch(bus, 29921)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29921/", client_id="big") as c: async with WsTestClient("ws://127.0.0.1:29921/", client_id="big") as c:
await c.recv_ready() await c.recv_ready()
@@ -500,7 +478,6 @@ async def test_large_message(bus: MagicMock) -> None:
async def test_unicode_roundtrip(bus: MagicMock) -> None: async def test_unicode_roundtrip(bus: MagicMock) -> None:
ch = _ch(bus, 29922) ch = _ch(bus, 29922)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29922/", client_id="u") as c: async with WsTestClient("ws://127.0.0.1:29922/", client_id="u") as c:
ready = await c.recv_ready() 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: async def test_rapid_fire(bus: MagicMock) -> None:
ch = _ch(bus, 29923) ch = _ch(bus, 29923)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29923/", client_id="r") as c: async with WsTestClient("ws://127.0.0.1:29923/", client_id="r") as c:
ready = await c.recv_ready() 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: async def test_invalid_json_as_plain_text(bus: MagicMock) -> None:
ch = _ch(bus, 29924) ch = _ch(bus, 29924)
t = asyncio.create_task(ch.start()) t = asyncio.create_task(ch.start())
await asyncio.sleep(0.3)
try: try:
async with WsTestClient("ws://127.0.0.1:29924/", client_id="j") as c: async with WsTestClient("ws://127.0.0.1:29924/", client_id="j") as c:
await c.recv_ready() 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 from __future__ import annotations
import asyncio import asyncio
import functools
import hashlib import hashlib
import hmac import hmac
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest 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.channels.websocket import WebSocketChannel, WebSocketConfig
from nanobot.session.manager import Session, SessionManager from nanobot.session.manager import Session, SessionManager
@@ -67,7 +67,7 @@ def _ch(
runtime_surface="browser", runtime_surface="browser",
runtime_capabilities_overrides=None, runtime_capabilities_overrides=None,
) )
return WebSocketChannel(cfg, bus, gateway=gateway) return InProcessHttpChannel(cfg, bus, gateway=gateway)
@pytest.fixture() @pytest.fixture()
@@ -86,14 +86,6 @@ def _fake_media_dir(root: Path):
return inner 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 # 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) url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None assert url_path is not None
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get(f"http://127.0.0.1:29920{url_path}") resp = await _http_get(f"http://127.0.0.1:29920{url_path}")
finally: finally:
@@ -256,7 +247,6 @@ async def test_media_route_serves_video_byte_ranges(
url_path = channel.gateway.media.sign_media_path(target) url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None assert url_path is not None
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get( resp = await _http_get(
f"http://127.0.0.1:29927{url_path}", 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) url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None assert url_path is not None
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get( resp = await _http_get(
f"http://127.0.0.1:29928{url_path}", 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) url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None assert url_path is not None
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get( resp = await _http_get(
f"http://127.0.0.1:29929{url_path}", 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}" forged = f"/api/media/{b64url_encode(forged_mac)}/{payload}"
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get(f"http://127.0.0.1:29921{forged}") resp = await _http_get(f"http://127.0.0.1:29921{forged}")
finally: 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): with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get(f"http://127.0.0.1:29922{url}") resp = await _http_get(f"http://127.0.0.1:29922{url}")
finally: finally:
@@ -418,7 +404,6 @@ async def test_media_route_404s_missing_file(
assert url_path is not None assert url_path is not None
target.unlink() # the file vanishes between signing and fetching target.unlink() # the file vanishes between signing and fetching
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get(f"http://127.0.0.1:29923{url_path}") resp = await _http_get(f"http://127.0.0.1:29923{url_path}")
finally: finally:
@@ -448,7 +433,6 @@ async def test_media_route_degrades_non_image_to_octet_stream(
).digest()[:16] ).digest()[:16]
url = f"/api/media/{b64url_encode(mac)}/{payload}" url = f"/api/media/{b64url_encode(mac)}/{payload}"
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get(f"http://127.0.0.1:29924{url}") resp = await _http_get(f"http://127.0.0.1:29924{url}")
finally: finally:
@@ -476,7 +460,6 @@ async def test_media_route_serves_svg_with_strict_csp(
url_path = channel.gateway.media.sign_media_path(target) url_path = channel.gateway.media.sign_media_path(target)
assert url_path is not None assert url_path is not None
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
resp = await _http_get(f"http://127.0.0.1:29928{url_path}") resp = await _http_get(f"http://127.0.0.1:29928{url_path}")
finally: finally:
@@ -515,7 +498,6 @@ async def test_session_messages_exposes_signed_media_urls(
channel = _ch(bus, session_manager=sm, port=29925) channel = _ch(bus, session_manager=sm, port=29925)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
auth = {"Authorization": f"Bearer {token}"} 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) channel = _ch(bus, session_manager=sm, port=29926)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media): with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
server_task = asyncio.create_task(channel.start()) server_task = asyncio.create_task(channel.start())
await asyncio.sleep(0.3)
try: try:
token = channel.gateway.tokens.issue_api_token(300) token = channel.gateway.tokens.issue_api_token(300)
resp = await _http_get( resp = await _http_get(
+38 -7
View File
@@ -37,6 +37,23 @@ def _make_channel() -> tuple[WeixinChannel, MessageBus]:
return channel, bus 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: def test_make_headers_includes_route_tag_when_configured() -> None:
bus = MessageBus() bus = MessageBus()
channel = WeixinChannel( channel = WeixinChannel(
@@ -446,7 +463,9 @@ async def test_poll_once_pauses_session_on_expired_errcode() -> None:
@pytest.mark.asyncio @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, _bus = _make_channel()
channel._running = True channel._running = True
channel._save_state = lambda: None channel._save_state = lambda: None
@@ -478,7 +497,9 @@ async def test_qr_login_refreshes_expired_qr_and_then_succeeds() -> None:
@pytest.mark.asyncio @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, _bus = _make_channel()
channel._running = True channel._running = True
channel._print_qr_code = lambda url: None 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 @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, _bus = _make_channel()
channel._running = True channel._running = True
channel._save_state = lambda: None 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 @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, _bus = _make_channel()
channel._running = True channel._running = True
channel._save_state = lambda: None 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 @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, _bus = _make_channel()
channel._running = True channel._running = True
channel._save_state = lambda: None 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 @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, _bus = _make_channel()
channel._running = True channel._running = True
channel._save_state = lambda: None 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 @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, _bus = _make_channel()
channel._running = True channel._running = True
channel._save_state = lambda: None channel._save_state = lambda: None
+91 -9
View File
@@ -21,6 +21,58 @@ from typing import Any
import httpx import httpx
import websockets import websockets
from websockets.asyncio.client import ClientConnection 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 @dataclass
@@ -89,11 +141,19 @@ class WsTestClient:
self._extra_headers = extra_headers self._extra_headers = extra_headers
self._ws: ClientConnection | None = None self._ws: ClientConnection | None = None
async def connect(self) -> None: async def connect(self, timeout: float = 2.0) -> None:
self._ws = await websockets.connect( deadline = asyncio.get_running_loop().time() + timeout
self._uri, while True:
additional_headers=self._extra_headers, 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: async def close(self) -> None:
if self._ws: if self._ws:
@@ -186,6 +246,31 @@ class WsTestClient:
# -- Token issuance helpers ----------------------------------------------- # -- 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( async def issue_token(
host: str = "127.0.0.1", host: str = "127.0.0.1",
port: int = 8765, port: int = 8765,
@@ -201,10 +286,7 @@ async def issue_token(
if secret: if secret:
headers["Authorization"] = f"Bearer {secret}" headers["Authorization"] = f"Bearer {secret}"
loop = asyncio.get_running_loop() resp = await http_get(url, headers)
resp = await loop.run_in_executor(
None, lambda: httpx.get(url, headers=headers, timeout=5.0)
)
try: try:
data = resp.json() data = resp.json()
except Exception: except Exception:
+51
View File
@@ -0,0 +1,51 @@
"""Cross-suite test infrastructure."""
from __future__ import annotations
import os
import ssl
import sys
from collections.abc import Iterator
import certifi
import pytest
@pytest.fixture(scope="session", autouse=True)
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
Loading certifi takes roughly 0.7 seconds per client on Windows. The test
suite constructs hundreds of clients while mocking their I/O. System roots
preserve certificate verification for accidental local requests; explicit
``cafile``, ``capath``, and ``cadata`` arguments still use the real loader.
"""
if sys.platform != "win32":
yield
return
original = ssl.create_default_context
certifi_path = os.path.normcase(os.path.abspath(certifi.where()))
def create_default_context(
purpose: ssl.Purpose = ssl.Purpose.SERVER_AUTH,
*,
cafile: str | None = None,
capath: str | None = None,
cadata: str | bytes | None = None,
) -> ssl.SSLContext:
requested_path = os.path.normcase(os.path.abspath(cafile)) if cafile else None
if requested_path == certifi_path and capath is None and cadata is None:
return original(purpose)
return original(
purpose,
cafile=cafile,
capath=capath,
cadata=cadata,
)
ssl.create_default_context = create_default_context
try:
yield
finally:
ssl.create_default_context = original
@@ -1,6 +1,6 @@
"""Tests for LLMProvider._enforce_role_alternation.""" """Tests for LLMProvider._enforce_role_alternation."""
from nanobot.providers.base import LLMProvider, _SYNTHETIC_USER_CONTENT from nanobot.providers.base import _SYNTHETIC_USER_CONTENT, LLMProvider
class TestEnforceRoleAlternation: class TestEnforceRoleAlternation:
+28 -28
View File
@@ -520,8 +520,8 @@ async def test_openrouter_keeps_model_name_intact() -> None:
mock_create = AsyncMock(return_value=_fake_chat_response()) mock_create = AsyncMock(return_value=_fake_chat_response())
spec = find_by_name("openrouter") spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_create client_instance.chat.completions.create = mock_create
provider = OpenAICompatProvider( provider = OpenAICompatProvider(
@@ -545,8 +545,8 @@ async def test_aihubmix_strips_model_prefix() -> None:
mock_create = AsyncMock(return_value=_fake_chat_response()) mock_create = AsyncMock(return_value=_fake_chat_response())
spec = find_by_name("aihubmix") spec = find_by_name("aihubmix")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_create client_instance.chat.completions.create = mock_create
provider = OpenAICompatProvider( provider = OpenAICompatProvider(
@@ -570,8 +570,8 @@ async def test_standard_provider_passes_model_through() -> None:
mock_create = AsyncMock(return_value=_fake_chat_response()) mock_create = AsyncMock(return_value=_fake_chat_response())
spec = find_by_name("deepseek") spec = find_by_name("deepseek")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_create client_instance.chat.completions.create = mock_create
provider = OpenAICompatProvider( provider = OpenAICompatProvider(
@@ -594,8 +594,8 @@ async def test_openai_compat_preserves_extra_content_on_tool_calls() -> None:
mock_create = AsyncMock(return_value=_fake_tool_call_response()) mock_create = AsyncMock(return_value=_fake_tool_call_response())
spec = find_by_name("gemini") spec = find_by_name("gemini")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_create client_instance.chat.completions.create = mock_create
provider = OpenAICompatProvider( provider = OpenAICompatProvider(
@@ -656,8 +656,8 @@ async def test_direct_openai_gpt5_uses_responses_api() -> None:
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses")) mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -687,8 +687,8 @@ async def test_direct_openai_reasoning_prefers_responses_api() -> None:
mock_responses = AsyncMock(return_value=_fake_responses_response("reasoned")) mock_responses = AsyncMock(return_value=_fake_responses_response("reasoned"))
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -716,8 +716,8 @@ async def test_direct_openai_gpt4o_stays_on_chat_completions() -> None:
mock_responses = AsyncMock(return_value=_fake_responses_response()) mock_responses = AsyncMock(return_value=_fake_responses_response())
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -741,8 +741,8 @@ async def test_openrouter_gpt5_stays_on_chat_completions() -> None:
mock_responses = AsyncMock(return_value=_fake_responses_response()) mock_responses = AsyncMock(return_value=_fake_responses_response())
spec = find_by_name("openrouter") spec = find_by_name("openrouter")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -767,8 +767,8 @@ async def test_direct_openai_streaming_gpt5_uses_responses_api() -> None:
mock_responses = AsyncMock(return_value=_fake_responses_stream("hi")) mock_responses = AsyncMock(return_value=_fake_responses_stream("hi"))
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -794,8 +794,8 @@ async def test_direct_openai_responses_404_falls_back_to_chat_completions() -> N
mock_responses = AsyncMock(side_effect=_FakeResponsesError(404, "Responses endpoint not supported")) mock_responses = AsyncMock(side_effect=_FakeResponsesError(404, "Responses endpoint not supported"))
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -820,8 +820,8 @@ async def test_direct_openai_open_circuit_skips_responses_api() -> None:
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses")) mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -851,8 +851,8 @@ async def test_direct_openai_stream_responses_unsupported_param_falls_back() ->
) )
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -877,8 +877,8 @@ async def test_direct_openai_responses_rate_limit_does_not_fallback() -> None:
mock_responses = AsyncMock(side_effect=_FakeResponsesError(429, "rate limit")) mock_responses = AsyncMock(side_effect=_FakeResponsesError(429, "rate limit"))
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_chat client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses client_instance.responses.create = mock_responses
@@ -1232,8 +1232,8 @@ async def test_openai_compat_stream_watchdog_returns_error_on_stall(monkeypatch)
mock_create = AsyncMock(return_value=_StalledStream()) mock_create = AsyncMock(return_value=_StalledStream())
spec = find_by_name("openai") spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as mock_client_class:
client_instance = MockClient.return_value client_instance = mock_client_class.return_value
client_instance.chat.completions.create = mock_create client_instance.chat.completions.create = mock_create
provider = OpenAICompatProvider( provider = OpenAICompatProvider(
@@ -5,9 +5,9 @@ import time
import pytest import pytest
from nanobot.providers.openai_compat_provider import ( from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider,
_RESPONSES_FAILURE_THRESHOLD, _RESPONSES_FAILURE_THRESHOLD,
_RESPONSES_PROBE_INTERVAL_S, _RESPONSES_PROBE_INTERVAL_S,
OpenAICompatProvider,
) )
+1 -2
View File
@@ -3,9 +3,8 @@ from __future__ import annotations
import subprocess import subprocess
import sys import sys
import textwrap import textwrap
from pathlib import Path
import tomllib import tomllib
from pathlib import Path
def test_source_checkout_import_uses_pyproject_version_without_metadata() -> None: def test_source_checkout_import_uses_pyproject_version_without_metadata() -> None:
+1 -2
View File
@@ -3,9 +3,8 @@ notebook JSON editing, and create-file semantics."""
import pytest import pytest
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, WriteFileTool
from nanobot.agent.tools import file_state from nanobot.agent.tools import file_state
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helpers # Helpers
+5 -1
View File
@@ -9,7 +9,11 @@ from unittest.mock import patch
import pytest import pytest
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
from nanobot.security.workspace_access import bind_workspace_scope, build_workspace_scope, reset_workspace_scope from nanobot.security.workspace_access import (
bind_workspace_scope,
build_workspace_scope,
reset_workspace_scope,
)
def _fake_resolve_private(hostname, port, family=0, type_=0): def _fake_resolve_private(hostname, port, family=0, type_=0):
+34 -21
View File
@@ -22,6 +22,30 @@ def _python_command(code: str) -> str:
return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}" return f"{shlex.quote(sys.executable)} -u -c {shlex.quote(code)}"
def _waiting_shell_command(initial: str, *, delayed: str | None = None) -> str:
"""Print deterministic output, then wait in the shell itself for stdin.
Long-lived Python children keep inherited pipes open after their parent
shell is terminated on Windows. These tests exercise exec-session control,
not process-tree semantics, so keep the waiter in the managed shell.
"""
if sys.platform == "win32":
def quote(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
parts = [f"Write-Output {quote(initial)}"]
if delayed is not None:
parts.extend(("Start-Sleep -Milliseconds 100", f"Write-Output {quote(delayed)}"))
parts.append("$null = [Console]::In.ReadLine()")
return "; ".join(parts)
parts = [f"printf '%s\\n' {shlex.quote(initial)}"]
if delayed is not None:
parts.extend(("sleep 0.1", f"printf '%s\\n' {shlex.quote(delayed)}"))
parts.append("IFS= read -r _")
return "; ".join(parts)
def _session_id(output: str) -> str: def _session_id(output: str) -> str:
match = re.search(r"session_id:\s*([0-9a-f]+)", output) match = re.search(r"session_id:\s*([0-9a-f]+)", output)
assert match, output assert match, output
@@ -204,16 +228,14 @@ def test_write_stdin_can_terminate_session(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=30, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = WriteStdinTool(manager=manager)
command = _python_command( command = _waiting_shell_command("ready")
"import time; print('ready', flush=True); time.sleep(30)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=100) initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial) sid = _session_id(initial)
waited = await stdin_tool.execute( waited = await stdin_tool.execute(
session_id=sid, session_id=sid,
wait_for="ready", wait_for="ready",
wait_timeout_ms=3000, wait_timeout_ms=1000,
yield_time_ms=0, yield_time_ms=0,
) )
result = await stdin_tool.execute( result = await stdin_tool.execute(
@@ -234,9 +256,7 @@ def test_write_stdin_accepts_max_output_tokens_alias(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = WriteStdinTool(manager=manager)
command = _python_command( command = _waiting_shell_command("A" * 2000)
"import time; print('A' * 2000, flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=0) initial = await exec_tool.execute(command=command, yield_time_ms=0)
sid = _session_id(initial) sid = _session_id(initial)
@@ -261,12 +281,12 @@ def test_write_stdin_preserves_completed_session_output_until_polled(tmp_path):
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = WriteStdinTool(manager=manager)
command = _python_command( command = _python_command(
"import time; print('ready', flush=True); " "import time; print('ready', flush=True); "
"time.sleep(1.0); print('done', flush=True)" "time.sleep(0.1); print('done', flush=True)"
) )
initial = await exec_tool.execute(command=command, yield_time_ms=300) initial = await exec_tool.execute(command=command, yield_time_ms=50)
sid = _session_id(initial) sid = _session_id(initial)
await asyncio.sleep(1.2) await asyncio.wait_for(manager._sessions[sid].process.wait(), timeout=2)
final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0) final = await stdin_tool.execute(session_id=sid, chars="", yield_time_ms=0)
return initial, final return initial, final
@@ -282,17 +302,14 @@ def test_write_stdin_can_wait_for_expected_output(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = WriteStdinTool(manager=manager)
command = _python_command( command = _waiting_shell_command("booting", delayed="ready")
"import time; print('booting', flush=True); "
"time.sleep(0.4); print('ready', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=100) initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial) sid = _session_id(initial)
waited = await stdin_tool.execute( waited = await stdin_tool.execute(
session_id=sid, session_id=sid,
wait_for="ready", wait_for="ready",
wait_timeout_ms=3000, wait_timeout_ms=1000,
yield_time_ms=0, yield_time_ms=0,
) )
cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0)
@@ -312,9 +329,7 @@ def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path):
manager = ExecSessionManager() manager = ExecSessionManager()
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = WriteStdinTool(manager=manager)
command = _python_command( command = _waiting_shell_command("booting")
"import time; print('booting', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=100) initial = await exec_tool.execute(command=command, yield_time_ms=100)
sid = _session_id(initial) sid = _session_id(initial)
@@ -365,9 +380,7 @@ def test_list_exec_sessions_reports_running_commands(tmp_path):
exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager)
list_tool = ListExecSessionsTool(manager=manager) list_tool = ListExecSessionsTool(manager=manager)
stdin_tool = WriteStdinTool(manager=manager) stdin_tool = WriteStdinTool(manager=manager)
command = _python_command( command = _waiting_shell_command("ready")
"import time; print('ready', flush=True); time.sleep(5)"
)
initial = await exec_tool.execute(command=command, yield_time_ms=500) initial = await exec_tool.execute(command=command, yield_time_ms=500)
sid = _session_id(initial) sid = _session_id(initial)
+16 -2
View File
@@ -49,9 +49,23 @@ async def test_probe_returns_false_for_closed_port():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_probe_uses_default_port_for_http(): async def test_probe_uses_default_port_for_http(monkeypatch: pytest.MonkeyPatch):
"""When no port in URL, should default to 80 (will fail -> False).""" """When no port is present, probe the validated address on port 80."""
attempts: list[tuple[str, int]] = []
monkeypatch.setattr(
"nanobot.agent.tools.mcp.resolve_url_target",
lambda _url: (True, "", ("93.184.216.34",)),
)
async def _open_connection(host: str, port: int):
attempts.append((host, port))
raise ConnectionRefusedError
monkeypatch.setattr("nanobot.agent.tools.mcp.asyncio.open_connection", _open_connection)
assert await _probe_http_url("http://unreachable-host.test/mcp") is False assert await _probe_http_url("http://unreachable-host.test/mcp") is False
assert attempts == [("93.184.216.34", 80)]
@pytest.mark.asyncio @pytest.mark.asyncio
+27
View File
@@ -811,6 +811,17 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1") monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate) monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable) monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
"nanobot.security.network.httpx.AsyncHTTPTransport",
lambda **_kwargs: httpx.MockTransport(
lambda request: httpx.Response(200, request=request)
),
)
monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient) monkeypatch.setattr(mcp_mod.httpx, "AsyncClient", FakeAsyncClient)
monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client) monkeypatch.setattr(sys.modules["mcp.client.sse"], "sse_client", _capturing_sse_client)
monkeypatch.setattr( monkeypatch.setattr(
@@ -832,6 +843,17 @@ async def test_connect_mcp_servers_env_proxy_adds_proxy_mounts_and_keeps_pinned_
def test_mcp_http_clients_no_proxy_env_keeps_pinned_direct_route(monkeypatch): def test_mcp_http_clients_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
monkeypatch.setenv("NO_PROXY", "mcp.example.com") monkeypatch.setenv("NO_PROXY", "mcp.example.com")
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr(
"nanobot.security.network.httpx.AsyncHTTPTransport",
lambda **_kwargs: httpx.MockTransport(
lambda request: httpx.Response(200, request=request)
),
)
kwargs = mcp_mod._pinned_transport_kwargs() kwargs = mcp_mod._pinned_transport_kwargs()
@@ -989,6 +1011,11 @@ async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
monkeypatch.setattr(mcp_mod, "validate_url_target", _validate) monkeypatch.setattr(mcp_mod, "validate_url_target", _validate)
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable) monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
monkeypatch.setattr(
mcp_mod,
"PinnedDNSAsyncTransport",
lambda: httpx.MockTransport(lambda request: httpx.Response(200, request=request)),
)
monkeypatch.setattr( monkeypatch.setattr(
sys.modules["mcp.client.streamable_http"], sys.modules["mcp.client.streamable_http"],
"streamable_http_client", "streamable_http_client",
+28 -18
View File
@@ -78,6 +78,11 @@ def _patch_web_fetch_fake_client(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
return FakeJinaResponse() return FakeJinaResponse()
monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient) monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
monkeypatch.setattr(
"nanobot.security.network.httpx.AsyncHTTPTransport",
lambda **_kwargs: object(),
)
return client_kwargs return client_kwargs
@@ -121,27 +126,12 @@ async def test_web_fetch_blocks_localhost_even_in_full_workspace_scope(tmp_path)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_web_fetch_result_contains_untrusted_flag(): async def test_web_fetch_result_contains_untrusted_flag(monkeypatch: pytest.MonkeyPatch):
"""When fetch succeeds, result JSON must include untrusted=True and the banner.""" """When fetch succeeds, result JSON must include untrusted=True and the banner."""
tool = WebFetchTool() tool = WebFetchTool()
_patch_web_fetch_fake_client(monkeypatch)
fake_html = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>" with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
class FakeResponse:
status_code = 200
url = "https://example.com/page"
text = fake_html
headers = {"content-type": "text/html"}
is_redirect = False
def raise_for_status(self): pass
def json(self): return {}
async def _fake_get(self, url, **kwargs):
return FakeResponse()
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public), \
patch("httpx.AsyncClient.get", _fake_get):
result = await tool.execute(url="https://example.com/page") result = await tool.execute(url="https://example.com/page")
data = json.loads(result) data = json.loads(result)
@@ -237,6 +227,11 @@ async def test_web_fetch_env_proxy_adds_proxy_mounts_and_keeps_pinned_transport(
def test_web_fetch_no_proxy_env_keeps_pinned_direct_route(monkeypatch): def test_web_fetch_no_proxy_env_keeps_pinned_direct_route(monkeypatch):
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080") monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
monkeypatch.setenv("NO_PROXY", "example.com") monkeypatch.setenv("NO_PROXY", "example.com")
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
monkeypatch.setattr(
"nanobot.security.network.httpx.AsyncHTTPTransport",
lambda **_kwargs: object(),
)
kwargs = web_module._fetch_client_kwargs(None, 15.0) kwargs = web_module._fetch_client_kwargs(None, 15.0)
@@ -265,6 +260,16 @@ async def test_web_fetch_does_not_fallback_after_pinned_dns_rebind_rejection(mon
monkeypatch.setattr(tool, "_fetch_jina", _unexpected_jina) monkeypatch.setattr(tool, "_fetch_jina", _unexpected_jina)
monkeypatch.setattr(tool, "_fetch_readability", _unexpected_readability) monkeypatch.setattr(tool, "_fetch_readability", _unexpected_readability)
class FailTransport(httpx.AsyncBaseTransport):
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
raise AssertionError("rebound target must be rejected before transport")
monkeypatch.setattr(
web_module,
"_pinned_dns_transport",
lambda: PinnedDNSAsyncTransport(inner=FailTransport()),
)
with patch("nanobot.security.network.socket.getaddrinfo", _rebinding_resolver): with patch("nanobot.security.network.socket.getaddrinfo", _rebinding_resolver):
result = await tool.execute(url="http://evil.example/page") result = await tool.execute(url="http://evil.example/page")
@@ -330,6 +335,7 @@ async def test_web_fetch_can_skip_jina_and_use_custom_user_agent(monkeypatch):
monkeypatch.setattr(tool, "_fetch_jina", _fail_jina) monkeypatch.setattr(tool, "_fetch_jina", _fail_jina)
monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "Hello world") monkeypatch.setattr(tool, "_extract_readable_html", lambda html, mode: "Hello world")
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient) monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool.execute(url="https://example.com/page") result = await tool.execute(url="https://example.com/page")
@@ -373,6 +379,7 @@ async def test_web_fetch_falls_back_when_readability_dependency_is_missing(monke
monkeypatch.setattr(tool, "_extract_readable_html", _missing_readability) monkeypatch.setattr(tool, "_extract_readable_html", _missing_readability)
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient) monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public): with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
result = await tool._fetch_readability("https://example.com/page", "markdown", 5000) result = await tool._fetch_readability("https://example.com/page", "markdown", 5000)
@@ -430,6 +437,7 @@ async def test_web_fetch_blocks_private_redirect_before_readability_request(monk
return FakeRedirectResponse() return FakeRedirectResponse()
monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient) monkeypatch.setattr(web_module.httpx, "AsyncClient", FakeClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
def resolve_public_start_only(hostname, port, family=0, type_=0): def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "attacker.example": if hostname == "attacker.example":
@@ -475,6 +483,7 @@ async def test_web_fetch_blocks_private_redirect_before_returning_image(monkeypa
super().__init__(*args, transport=transport, **kwargs) super().__init__(*args, transport=transport, **kwargs)
monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", TransportAsyncClient) monkeypatch.setattr("nanobot.agent.tools.web.httpx.AsyncClient", TransportAsyncClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
def resolve_public_start_only(hostname, port, family=0, type_=0): def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "example.com": if hostname == "example.com":
@@ -515,6 +524,7 @@ async def test_web_fetch_does_not_request_private_redirect_target(monkeypatch):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
monkeypatch.setattr(web_module.httpx, "AsyncClient", TransportAsyncClient) monkeypatch.setattr(web_module.httpx, "AsyncClient", TransportAsyncClient)
monkeypatch.setattr(web_module, "_pinned_dns_transport", lambda: object())
def resolve_public_start_only(hostname, port, family=0, type_=0): def resolve_public_start_only(hostname, port, family=0, type_=0):
if hostname == "attacker.example": if hostname == "attacker.example":
+15 -9
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from contextlib import contextmanager
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
@@ -41,9 +42,14 @@ class FakeClient:
return FakeResponse() return FakeResponse()
def _patch_env(): @contextmanager
return patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public), \ def _patched_web_fetch():
patch("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient) with (
patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public),
patch("nanobot.agent.tools.web.httpx.AsyncClient", FakeClient),
patch("nanobot.agent.tools.web._pinned_dns_transport", lambda: object()),
):
yield
# --- urlparse / _validate_url level tests --- # --- urlparse / _validate_url level tests ---
@@ -77,7 +83,7 @@ def test_backtick_url_produces_empty_scheme_in_urlparse():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_strips_backticks_and_succeeds(): async def test_execute_strips_backticks_and_succeeds():
tool = WebFetchTool() tool = WebFetchTool()
with _patch_env()[0], _patch_env()[1]: with _patched_web_fetch():
result = await tool.execute(url="`https://example.com/page`") result = await tool.execute(url="`https://example.com/page`")
data = json.loads(result) data = json.loads(result)
assert "error" not in data, f"unexpected error: {data}" assert "error" not in data, f"unexpected error: {data}"
@@ -86,7 +92,7 @@ async def test_execute_strips_backticks_and_succeeds():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_strips_double_quotes_and_succeeds(): async def test_execute_strips_double_quotes_and_succeeds():
tool = WebFetchTool() tool = WebFetchTool()
with _patch_env()[0], _patch_env()[1]: with _patched_web_fetch():
result = await tool.execute(url='"https://example.com/page"') result = await tool.execute(url='"https://example.com/page"')
data = json.loads(result) data = json.loads(result)
assert "error" not in data, f"unexpected error: {data}" assert "error" not in data, f"unexpected error: {data}"
@@ -95,7 +101,7 @@ async def test_execute_strips_double_quotes_and_succeeds():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_strips_single_quotes_and_succeeds(): async def test_execute_strips_single_quotes_and_succeeds():
tool = WebFetchTool() tool = WebFetchTool()
with _patch_env()[0], _patch_env()[1]: with _patched_web_fetch():
result = await tool.execute(url="'https://example.com/page'") result = await tool.execute(url="'https://example.com/page'")
data = json.loads(result) data = json.loads(result)
assert "error" not in data, f"unexpected error: {data}" assert "error" not in data, f"unexpected error: {data}"
@@ -104,7 +110,7 @@ async def test_execute_strips_single_quotes_and_succeeds():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_strips_space_and_backticks(): async def test_execute_strips_space_and_backticks():
tool = WebFetchTool() tool = WebFetchTool()
with _patch_env()[0], _patch_env()[1]: with _patched_web_fetch():
result = await tool.execute(url=" `https://example.com/page` ") result = await tool.execute(url=" `https://example.com/page` ")
data = json.loads(result) data = json.loads(result)
assert "error" not in data, f"unexpected error: {data}" assert "error" not in data, f"unexpected error: {data}"
@@ -113,7 +119,7 @@ async def test_execute_strips_space_and_backticks():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_strips_mixed_markdown_and_quotes(): async def test_execute_strips_mixed_markdown_and_quotes():
tool = WebFetchTool() tool = WebFetchTool()
with _patch_env()[0], _patch_env()[1]: with _patched_web_fetch():
result = await tool.execute(url='"`https://example.com/page`"') result = await tool.execute(url='"`https://example.com/page`"')
data = json.loads(result) data = json.loads(result)
assert "error" not in data, f"unexpected error: {data}" assert "error" not in data, f"unexpected error: {data}"
@@ -122,7 +128,7 @@ async def test_execute_strips_mixed_markdown_and_quotes():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_keeps_case_insensitive_http_scheme(): async def test_execute_keeps_case_insensitive_http_scheme():
tool = WebFetchTool() tool = WebFetchTool()
with _patch_env()[0], _patch_env()[1]: with _patched_web_fetch():
result = await tool.execute(url="HTTPS://example.com/page") result = await tool.execute(url="HTTPS://example.com/page")
data = json.loads(result) data = json.loads(result)
assert "error" not in data, f"unexpected error: {data}" assert "error" not in data, f"unexpected error: {data}"
+1 -1
View File
@@ -1,6 +1,7 @@
"""Tests for abbreviate_path utility.""" """Tests for abbreviate_path utility."""
import os import os
from nanobot.utils.path import abbreviate_path from nanobot.utils.path import abbreviate_path
@@ -9,7 +10,6 @@ class TestAbbreviatePathShort:
assert abbreviate_path("/home/user/file.py") == "/home/user/file.py" assert abbreviate_path("/home/user/file.py") == "/home/user/file.py"
def test_exact_max_len_unchanged(self): def test_exact_max_len_unchanged(self):
path = "/a/b/c" # 7 chars
assert abbreviate_path("/a/b/c", max_len=7) == "/a/b/c" assert abbreviate_path("/a/b/c", max_len=7) == "/a/b/c"
def test_basename_only(self): def test_basename_only(self):
+3 -3
View File
@@ -2,16 +2,16 @@
from __future__ import annotations from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from nanobot.utils.helpers import build_status_content
from nanobot.utils.searchusage import ( from nanobot.utils.searchusage import (
SearchUsageInfo, SearchUsageInfo,
_parse_tavily_usage, _parse_tavily_usage,
fetch_search_usage, fetch_search_usage,
) )
from nanobot.utils.helpers import build_status_content
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SearchUsageInfo.format() tests # SearchUsageInfo.format() tests
@@ -40,7 +40,13 @@ describe("DiffSyntaxHighlight with Prism", () => {
</ThemeProvider>, </ThemeProvider>,
); );
const highlighted = await screen.findByTestId("syntax-highlighted-diff-hunk"); // Full-suite workers can keep the first Prism grammar import busy for more
// than Testing Library's one-second default, especially on Windows.
const highlighted = await screen.findByTestId(
"syntax-highlighted-diff-hunk",
{},
{ timeout: 10_000 },
);
await waitFor( await waitFor(
() => { () => {
const tokens = highlighted.querySelectorAll<HTMLElement>( const tokens = highlighted.querySelectorAll<HTMLElement>(