mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(webui): add integrated Vite dev mode (#5239)
This commit is contained in:
+183
-1
@@ -3,7 +3,8 @@ import json
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
from contextlib import suppress
|
||||
import urllib.error
|
||||
from contextlib import contextmanager, suppress
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
@@ -33,6 +34,7 @@ from nanobot.providers.openai_codex_provider import _strip_model_prefix
|
||||
from nanobot.providers.registry import find_by_name
|
||||
from nanobot.providers.unconfigured_provider import UnconfiguredProvider
|
||||
from nanobot.session.webui_turns import WebuiTurnRoutePolicy
|
||||
from nanobot.webui.dev import WebUIDevError
|
||||
from nanobot.webui.metadata import (
|
||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
||||
WEBUI_TURN_METADATA_KEY,
|
||||
@@ -2176,6 +2178,171 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
||||
assert "Press Ctrl+C here to stop nanobot" in compact_output
|
||||
|
||||
|
||||
def test_webui_dev_rejects_background_before_creating_config(tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["webui", "--dev", "--background", "--yes", "--config", str(config_file)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "--dev cannot be combined with --background" in result.stdout
|
||||
assert not config_file.exists()
|
||||
|
||||
|
||||
def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}", encoding="utf-8")
|
||||
seen: dict[str, object] = {}
|
||||
_patch_webui_provider_ready(monkeypatch)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||
|
||||
@contextmanager
|
||||
def fake_dev_server(**kwargs):
|
||||
seen["dev_kwargs"] = kwargs
|
||||
seen["dev_running"] = True
|
||||
dev_server = SimpleNamespace(
|
||||
url=kwargs["browser_url"],
|
||||
ensure_running=lambda: None,
|
||||
)
|
||||
seen["dev_server"] = dev_server
|
||||
try:
|
||||
yield dev_server
|
||||
finally:
|
||||
seen["dev_running"] = False
|
||||
|
||||
def fake_run_gateway(_config: Config, **kwargs) -> None:
|
||||
assert seen["dev_running"] is True
|
||||
seen["gateway_kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.webui.run_webui_dev_server", fake_dev_server)
|
||||
monkeypatch.setattr("nanobot.cli.webui._run_gateway", fake_run_gateway)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"webui",
|
||||
"--dev",
|
||||
"--config",
|
||||
str(config_file),
|
||||
"--port",
|
||||
"8899",
|
||||
"--gateway-port",
|
||||
"18888",
|
||||
"--yes",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
dev_kwargs = seen["dev_kwargs"]
|
||||
assert isinstance(dev_kwargs, dict)
|
||||
assert dev_kwargs["target_url"] == "http://127.0.0.1:8899"
|
||||
browser_url = dev_kwargs["browser_url"]
|
||||
assert isinstance(browser_url, str)
|
||||
assert browser_url.startswith("http://127.0.0.1:5173/#/?bootstrapSecret=")
|
||||
gateway_kwargs = seen["gateway_kwargs"]
|
||||
assert isinstance(gateway_kwargs, dict)
|
||||
assert gateway_kwargs == {
|
||||
"port": 18888,
|
||||
"open_browser_url": browser_url,
|
||||
"open_browser_ready_url": "http://127.0.0.1:8899/webui/bootstrap",
|
||||
"webui_static_dist": False,
|
||||
"webui_bundle_mode": "skip",
|
||||
"unconfigured_provider_error": None,
|
||||
"webui_dev_server": seen["dev_server"],
|
||||
}
|
||||
assert seen["dev_running"] is False
|
||||
assert "WebUI dev: http://127.0.0.1:5173/#/?bootstrapSecret=<redacted>" in re.sub(
|
||||
r"\s+", " ", _strip_ansi(result.stdout)
|
||||
)
|
||||
|
||||
|
||||
def test_webui_dev_waits_for_external_gateway_via_health_endpoint(monkeypatch) -> None:
|
||||
health_results = iter((True, False))
|
||||
health_calls: list[tuple[str, int]] = []
|
||||
sidecar_checks = 0
|
||||
|
||||
def fake_health(host: str, port: int) -> bool:
|
||||
health_calls.append((host, port))
|
||||
return next(health_results)
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", fake_health)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._webui_endpoint_reachable",
|
||||
lambda _url: pytest.fail("must not probe the WebSocket endpoint while waiting"),
|
||||
)
|
||||
monkeypatch.setattr("time.sleep", lambda _seconds: None)
|
||||
|
||||
def ensure_sidecar_running() -> None:
|
||||
nonlocal sidecar_checks
|
||||
sidecar_checks += 1
|
||||
|
||||
dev_server = MagicMock()
|
||||
dev_server.ensure_running.side_effect = ensure_sidecar_running
|
||||
cli_webui._wait_with_existing_foreground_gateway("127.0.0.1", 18888, dev_server)
|
||||
|
||||
assert health_calls == [("127.0.0.1", 18888), ("127.0.0.1", 18888)]
|
||||
assert sidecar_checks == 2
|
||||
|
||||
|
||||
async def test_webui_dev_monitor_fails_when_sidecar_exits() -> None:
|
||||
dev_server = MagicMock()
|
||||
dev_server.ensure_running.side_effect = WebUIDevError(
|
||||
"WebUI development server exited unexpectedly (code 23)"
|
||||
)
|
||||
|
||||
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
||||
await cli_gateway_runtime._watch_webui_dev_server(
|
||||
dev_server,
|
||||
asyncio.Event(),
|
||||
poll_interval_s=0,
|
||||
)
|
||||
|
||||
|
||||
async def test_webui_dev_monitor_ignores_an_expected_gateway_shutdown() -> None:
|
||||
dev_server = MagicMock()
|
||||
shutdown_event = asyncio.Event()
|
||||
shutdown_event.set()
|
||||
|
||||
await cli_gateway_runtime._watch_webui_dev_server(
|
||||
dev_server,
|
||||
shutdown_event,
|
||||
poll_interval_s=0,
|
||||
)
|
||||
|
||||
dev_server.ensure_running.assert_not_called()
|
||||
|
||||
|
||||
def test_browser_readiness_accepts_http_auth_response(monkeypatch) -> None:
|
||||
def auth_required(*_args, **_kwargs):
|
||||
raise urllib.error.HTTPError(
|
||||
"http://127.0.0.1:8765/webui/bootstrap",
|
||||
401,
|
||||
"authentication required",
|
||||
hdrs=None,
|
||||
fp=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", auth_required)
|
||||
|
||||
assert cli_gateway_runtime._http_endpoint_responding(
|
||||
"http://127.0.0.1:8765/webui/bootstrap"
|
||||
) is True
|
||||
|
||||
|
||||
def test_browser_readiness_rejects_connection_error(monkeypatch) -> None:
|
||||
def unavailable(*_args, **_kwargs):
|
||||
raise urllib.error.URLError("connection refused")
|
||||
|
||||
monkeypatch.setattr("urllib.request.urlopen", unavailable)
|
||||
|
||||
assert cli_gateway_runtime._http_endpoint_responding(
|
||||
"http://127.0.0.1:8765/webui/bootstrap"
|
||||
) is False
|
||||
|
||||
|
||||
def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
seen: dict[str, object] = {}
|
||||
@@ -2506,6 +2673,21 @@ def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> No
|
||||
assert "Gateway stopped" in output
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
|
||||
class _FakeRuntime:
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
def sidecar_exited() -> None:
|
||||
raise WebUIDevError("WebUI development server exited unexpectedly (code 23)")
|
||||
|
||||
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
||||
cli_webui_support._attach_to_background_gateway(
|
||||
_FakeRuntime(),
|
||||
poll_hook=sidecar_exited,
|
||||
)
|
||||
|
||||
|
||||
def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.webui.dev import (
|
||||
WebUIDevError,
|
||||
WebUIDevServer,
|
||||
run_webui_dev_server,
|
||||
start_webui_dev_server,
|
||||
webui_dev_browser_url,
|
||||
webui_dev_proxy_target,
|
||||
)
|
||||
|
||||
|
||||
class _FakeProcess:
|
||||
def __init__(self) -> None:
|
||||
self.pid = 123
|
||||
self.returncode: int | None = None
|
||||
self.terminated = False
|
||||
self.killed = False
|
||||
|
||||
def poll(self) -> int | None:
|
||||
return self.returncode
|
||||
|
||||
def terminate(self) -> None:
|
||||
self.terminated = True
|
||||
self.returncode = 0
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
self.returncode = -9
|
||||
|
||||
def wait(self, *, timeout: float) -> int:
|
||||
if self.returncode is None:
|
||||
raise subprocess.TimeoutExpired("vite", timeout)
|
||||
return self.returncode
|
||||
|
||||
|
||||
def _write_webui_source(source: Path, *, with_vite: bool = True) -> Path:
|
||||
source.mkdir(parents=True)
|
||||
(source / "package.json").write_text("{}", encoding="utf-8")
|
||||
(source / "bun.lock").write_text("", encoding="utf-8")
|
||||
vite_cli = source / "node_modules" / "vite" / "bin" / "vite.js"
|
||||
if with_vite:
|
||||
vite_cli.parent.mkdir(parents=True)
|
||||
vite_cli.write_text("", encoding="utf-8")
|
||||
return vite_cli
|
||||
|
||||
|
||||
def test_dev_urls_preserve_secret_and_target_only_the_backend_origin() -> None:
|
||||
webui_url = "http://127.0.0.1:8899/#/?bootstrapSecret=secret"
|
||||
|
||||
assert webui_dev_browser_url(webui_url) == (
|
||||
"http://127.0.0.1:5173/#/?bootstrapSecret=secret"
|
||||
)
|
||||
assert webui_dev_proxy_target(webui_url) == "http://127.0.0.1:8899"
|
||||
|
||||
|
||||
def test_start_webui_dev_server_uses_vite_directly_and_sets_proxy_target(
|
||||
monkeypatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
source = tmp_path / "webui"
|
||||
vite_cli = _write_webui_source(source)
|
||||
process = _FakeProcess()
|
||||
popen_calls: list[tuple[list[str], dict[str, object]]] = []
|
||||
reachability = iter((False, True))
|
||||
output: list[str] = []
|
||||
|
||||
def fake_popen(command: list[str], **kwargs):
|
||||
popen_calls.append((command, kwargs))
|
||||
return process
|
||||
|
||||
monkeypatch.setattr(
|
||||
"nanobot.webui.dev.shutil.which",
|
||||
lambda name: "node" if name == "node" else None,
|
||||
)
|
||||
|
||||
server = start_webui_dev_server(
|
||||
target_url="http://127.0.0.1:8899",
|
||||
browser_url="http://127.0.0.1:5173/#/?bootstrapSecret=secret",
|
||||
source_dir=source,
|
||||
runner="bun",
|
||||
environ={"EXISTING": "value"},
|
||||
output=output.append,
|
||||
popen=fake_popen,
|
||||
endpoint_reachable=lambda *_args, **_kwargs: next(reachability),
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
|
||||
assert server.process is process
|
||||
command, kwargs = popen_calls[0]
|
||||
assert command == ["node", str(vite_cli)]
|
||||
assert kwargs["cwd"] == source
|
||||
assert kwargs["env"] == {
|
||||
"EXISTING": "value",
|
||||
"NANOBOT_API_URL": "http://127.0.0.1:8899",
|
||||
}
|
||||
assert output == ["WebUI dev server: http://127.0.0.1:5173/"]
|
||||
assert "secret" not in output[0]
|
||||
|
||||
|
||||
def test_dev_server_installs_locked_dependencies_when_vite_is_missing(tmp_path: Path) -> None:
|
||||
source = tmp_path / "webui"
|
||||
vite_cli = _write_webui_source(source, with_vite=False)
|
||||
commands: list[list[str]] = []
|
||||
process = _FakeProcess()
|
||||
reachability = iter((False, True))
|
||||
|
||||
def fake_run(command: list[str], *, cwd: Path, check: bool):
|
||||
commands.append(command)
|
||||
assert cwd == source
|
||||
assert check is True
|
||||
vite_cli.parent.mkdir(parents=True)
|
||||
vite_cli.write_text("", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
start_webui_dev_server(
|
||||
target_url="http://127.0.0.1:8765",
|
||||
browser_url="http://127.0.0.1:5173",
|
||||
source_dir=source,
|
||||
runner="bun",
|
||||
popen=lambda *_args, **_kwargs: process,
|
||||
subprocess_run=fake_run,
|
||||
endpoint_reachable=lambda *_args, **_kwargs: next(reachability),
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
|
||||
assert commands == [["bun", "install", "--frozen-lockfile"]]
|
||||
|
||||
|
||||
def test_dev_server_requires_a_source_checkout(tmp_path: Path) -> None:
|
||||
with pytest.raises(WebUIDevError, match="source checkout"):
|
||||
start_webui_dev_server(
|
||||
target_url="http://127.0.0.1:8765",
|
||||
browser_url="http://127.0.0.1:5173",
|
||||
source_dir=tmp_path / "missing",
|
||||
)
|
||||
|
||||
|
||||
def test_dev_server_stop_terminates_and_reaps_the_direct_process() -> None:
|
||||
process = _FakeProcess()
|
||||
server = WebUIDevServer(process=process)
|
||||
|
||||
server.stop()
|
||||
|
||||
assert process.terminated is True
|
||||
assert process.killed is False
|
||||
assert process.returncode == 0
|
||||
|
||||
|
||||
def test_dev_server_reports_an_unexpected_exit() -> None:
|
||||
process = _FakeProcess()
|
||||
process.returncode = 23
|
||||
server = WebUIDevServer(process=process)
|
||||
|
||||
with pytest.raises(WebUIDevError, match=r"exited unexpectedly \(code 23\)"):
|
||||
server.ensure_running()
|
||||
|
||||
|
||||
def test_dev_server_context_stops_the_child(monkeypatch) -> None:
|
||||
process = _FakeProcess()
|
||||
process.returncode = 0
|
||||
server = type("Server", (), {"process": process})()
|
||||
stopped: list[bool] = []
|
||||
server.stop = lambda: stopped.append(True)
|
||||
monkeypatch.setattr("nanobot.webui.dev.start_webui_dev_server", lambda **_kwargs: server)
|
||||
|
||||
with run_webui_dev_server(target_url="unused", browser_url="unused") as running:
|
||||
assert running is server
|
||||
|
||||
assert stopped == [True]
|
||||
Reference in New Issue
Block a user