test: speed up CI and harden the suite
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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,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):
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
Reference in New Issue
Block a user