"""Black-box smoke test for the real gateway WebUI transport.""" from __future__ import annotations import asyncio import json import socket import subprocess import sys import time from pathlib import Path from urllib.parse import quote import httpx import pytest import websockets def _free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return int(sock.getsockname()[1]) def _write_smoke_config(path: Path, *, workspace: Path, ws_port: int, gateway_port: int) -> None: config = { "agents": { "defaults": { "workspace": str(workspace), "provider": "custom", "model": "custom/smoke-model", "maxToolIterations": 1, "dream": {"enabled": False}, } }, "providers": { "custom": { "apiKey": "smoke-no-external-call", "apiBase": "http://127.0.0.1:9/v1", } }, "channels": { "websocket": { "enabled": True, "host": "127.0.0.1", "port": ws_port, "allowFrom": ["*"], } }, "gateway": { "host": "127.0.0.1", "port": gateway_port, "heartbeat": {"enabled": False}, }, } path.write_text(json.dumps(config), encoding="utf-8") def _start_gateway(config_path: Path, log_path: Path) -> subprocess.Popen[bytes]: log_file = log_path.open("wb") try: process = subprocess.Popen( [ sys.executable, "-m", "nanobot", "gateway", "--config", str(config_path), ], cwd=Path(__file__).resolve().parents[2], stdout=log_file, stderr=subprocess.STDOUT, ) finally: log_file.close() return process def _stop_gateway(process: subprocess.Popen[bytes]) -> None: if process.poll() is not None: return process.terminate() try: process.wait(timeout=10) except subprocess.TimeoutExpired: process.kill() process.wait(timeout=10) def _get_json(url: str, *, token: str | None = None) -> dict: headers = {"Authorization": f"Bearer {token}"} if token else {} response = httpx.get(url, headers=headers, timeout=5.0, trust_env=False) response.raise_for_status() return response.json() def _wait_for_bootstrap(base_url: str, process: subprocess.Popen[bytes], log_path: Path) -> dict: deadline = time.monotonic() + 20 last_error: Exception | None = None while time.monotonic() < deadline: if process.poll() is not None: break try: return _get_json(f"{base_url}/webui/bootstrap") except (httpx.HTTPError, OSError) as exc: last_error = exc time.sleep(0.2) logs = log_path.read_text(encoding="utf-8", errors="replace") raise AssertionError(f"gateway did not start; last_error={last_error!r}\n{logs}") async def _recv_until(ws: websockets.WebSocketClientProtocol, event: str) -> dict: deadline = time.monotonic() + 20 while time.monotonic() < deadline: raw = await asyncio.wait_for(ws.recv(), timeout=5) payload = json.loads(raw) if payload.get("event") == event: return payload raise AssertionError(f"websocket event {event!r} was not received") @pytest.mark.asyncio async def test_gateway_webui_bootstrap_message_and_thread_hydration(tmp_path: Path) -> None: ws_port = _free_port() gateway_port = _free_port() workspace = tmp_path / "workspace" workspace.mkdir() config_path = tmp_path / "config.json" log_path = tmp_path / "gateway.log" _write_smoke_config( config_path, workspace=workspace, ws_port=ws_port, gateway_port=gateway_port, ) process = _start_gateway(config_path, log_path) base_url = f"http://127.0.0.1:{ws_port}" try: bootstrap = _wait_for_bootstrap(base_url, process, log_path) assert bootstrap["model_name"] == "custom/smoke-model" ws_url = f'{bootstrap["ws_url"]}?token={bootstrap["token"]}&client_id=smoke' async with websockets.connect(ws_url) as ws: ready = await _recv_until(ws, "ready") assert ready["client_id"] == "smoke" await ws.send(json.dumps({"type": "new_chat"})) attached = await _recv_until(ws, "attached") chat_id = attached["chat_id"] await _recv_until(ws, "session_updated") await ws.send(json.dumps({ "type": "message", "chat_id": chat_id, "content": "/model", "webui": True, "turn_id": "smoke-turn", })) answer = await _recv_until(ws, "message") assert "Current model: `custom/smoke-model`" in answer["text"] await _recv_until(ws, "turn_end") api_token = _wait_for_bootstrap(base_url, process, log_path)["token"] sessions = _get_json(f"{base_url}/api/sessions", token=api_token) key = f"websocket:{chat_id}" assert key in {row["key"] for row in sessions["sessions"]} encoded_key = quote(key, safe="") thread = _get_json( f"{base_url}/api/sessions/{encoded_key}/webui-thread", token=api_token, ) contents = [str(message.get("content") or "") for message in thread["messages"]] assert "/model" in contents assert any("Current model: `custom/smoke-model`" in text for text in contents) finally: _stop_gateway(process)