feat(webui): add integrated Vite dev mode (#5239)

This commit is contained in:
chengyongru 2026-08-04 16:14:32 +08:00 committed by GitHub
parent 4e8702a47b
commit 2fe135db3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 762 additions and 21 deletions

View File

@ -104,6 +104,7 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|---|---|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
@ -111,6 +112,10 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
`--dev` is a foreground source-checkout workflow and cannot be combined with `--background`.
It installs frontend dependencies when `webui/node_modules` is missing, proxies to the configured
WebSocket channel port, and stops Vite together with the foreground gateway.
## Gateway
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. Most local browser users should start with `nanobot webui`; use `gateway` directly for service management, chat app operation, and advanced deployment. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.

View File

@ -7,7 +7,7 @@ from pathlib import Path
from typing import Any
from nanobot.channels.contracts import channel_field_value
from nanobot.config.loader import get_config_path
from nanobot.config.paths import get_config_path
def local_state_present(section: Any) -> bool:

View File

@ -25,6 +25,7 @@ from nanobot.cli.webui_support import (
_tcp_endpoint_reachable,
_webui_browser_url,
_webui_channel_enabled,
_webui_display_url,
_webui_endpoint_reachable,
)
from nanobot.config.paths import is_default_workspace
@ -34,6 +35,7 @@ from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
from nanobot.utils.helpers import sync_workspace_templates
from nanobot.webui.build import BuildMode
from nanobot.webui.dev import WebUIDevError, WebUIDevServer
from nanobot.webui.sidebar_state import read_webui_sidebar_state
__all__ = ["_run_gateway"]
@ -41,6 +43,34 @@ __all__ = ["_run_gateway"]
console = Console()
def _http_endpoint_responding(url: str, *, timeout_s: float = 0.25) -> bool:
"""Return whether an HTTP endpoint responds, including with an auth error."""
import urllib.error
import urllib.request
try:
with urllib.request.urlopen(url, timeout=timeout_s):
return True
except urllib.error.HTTPError:
return True
except (OSError, urllib.error.URLError, TimeoutError, ValueError):
return False
async def _watch_webui_dev_server(
server: WebUIDevServer,
shutdown_event: asyncio.Event,
*,
poll_interval_s: float = 0.2,
) -> None:
"""Fail the foreground gateway when its owned Vite sidecar exits."""
while not shutdown_event.is_set():
await asyncio.sleep(poll_interval_s)
if shutdown_event.is_set():
return
server.ensure_running()
def _signal_name(signum: int) -> str:
with suppress(ValueError):
return signal.Signals(signum).name
@ -258,12 +288,14 @@ def _run_gateway(
*,
port: int | None = None,
open_browser_url: str | None = None,
open_browser_ready_url: str | None = None,
webui_static_dist: bool = True,
webui_bundle_mode: BuildMode = "warn",
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
health_server_enabled: bool = True,
unconfigured_provider_error: str | None = None,
webui_dev_server: WebUIDevServer | None = None,
) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.model_presets import load_model_preset_catalog
@ -760,10 +792,21 @@ def _run_gateway(
import webbrowser
from urllib.parse import urlparse
# Channels start asynchronously. When the caller supplies a backend
# readiness route, wait for an actual HTTP response rather than probing
# the WebSocket listener with an incomplete TCP connection.
if open_browser_ready_url:
for _ in range(40): # ~4s max per listener
if await asyncio.to_thread(
_http_endpoint_responding,
open_browser_ready_url,
):
break
await asyncio.sleep(0.1)
parsed = urlparse(open_browser_url)
target_host = parsed.hostname or config.gateway.host or "127.0.0.1"
target_port = parsed.port or port
# Channels start asynchronously; a short poll lets us avoid racing the bind.
for _ in range(40): # ~4s max
try:
_reader, writer = await asyncio.open_connection(
@ -776,11 +819,12 @@ def _run_gateway(
break
except OSError:
await asyncio.sleep(0.1)
display_url = _webui_display_url(open_browser_url)
try:
webbrowser.open(open_browser_url)
console.print(f"[green]✓[/green] Opened browser at {open_browser_url}")
console.print(f"[green]✓[/green] Opened browser at {display_url}")
except Exception as e:
console.print(f"[yellow]Could not open browser ({e}); visit {open_browser_url}[/yellow]")
console.print(f"[yellow]Could not open browser ({e}); visit {display_url}[/yellow]")
async def run() -> None:
tasks: list[asyncio.Task[Any]] = []
@ -827,6 +871,11 @@ def _run_gateway(
_open_browser_when_ready(),
name="nanobot-open-browser",
))
if webui_dev_server is not None:
tasks.append(asyncio.create_task(
_watch_webui_dev_server(webui_dev_server, shutdown_event),
name="nanobot-webui-dev-server",
))
runtime_tasks = asyncio.gather(*tasks)
shutdown_task = asyncio.create_task(
shutdown_event.wait(),
@ -842,6 +891,8 @@ def _run_gateway(
runtime_tasks.cancel()
except KeyboardInterrupt:
console.print("\nShutting down...")
except WebUIDevError:
raise
except Exception:
import traceback

View File

@ -39,10 +39,39 @@ from nanobot.cli.webui_support import (
)
from nanobot.config.paths import get_workspace_path
from nanobot.utils.helpers import sync_workspace_templates
from nanobot.webui.dev import (
WebUIDevError,
WebUIDevServer,
run_webui_dev_server,
webui_dev_browser_url,
webui_dev_proxy_target,
)
console = Console()
def _wait_with_existing_foreground_gateway(
gateway_host: str,
gateway_port: int,
dev_server: WebUIDevServer,
) -> None:
"""Keep a Vite sidecar alive without taking ownership of an external gateway."""
import time
console.print(
"[dim]Vite is attached to the existing foreground gateway. "
"Press Ctrl+C to stop Vite; the gateway will keep running.[/dim]"
)
try:
while True:
dev_server.ensure_running()
if not _gateway_health_ready(gateway_host, gateway_port):
break
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping the WebUI dev server.[/yellow]")
def webui(
port: int | None = typer.Option(None, "--port", "-p", help="WebUI port"),
gateway_port: int | None = typer.Option(
@ -57,6 +86,11 @@ def webui(
"--background",
help="Keep the gateway running after this command exits",
),
dev: bool = typer.Option(
False,
"--dev",
help="Run the Vite development server with live frontend updates",
),
no_open: bool = typer.Option(False, "--no-open", help="Do not open a browser"),
yes: bool = typer.Option(
False,
@ -70,6 +104,9 @@ def webui(
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
cli_terminal._ensure_interactive_tty_mode()
if dev and background:
console.print("[red]Error: --dev cannot be combined with --background.[/red]")
raise typer.Exit(1)
config_path = _resolve_webui_config_path(config)
created_config = not config_path.exists()
if created_config:
@ -143,8 +180,13 @@ def webui(
runtime_config = _load_runtime_config(str(config_path), workspace)
effective_gateway_port = gateway_port if gateway_port is not None else runtime_config.gateway.port
dev_browser_url = webui_dev_browser_url(webui_url) if dev else None
console.print()
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
if dev_browser_url:
console.print(f"WebUI dev: [cyan]{_webui_display_url(dev_browser_url)}[/cyan]")
console.print(f"WebUI gateway: [cyan]{_webui_display_url(webui_url)}[/cyan]")
else:
console.print(f"WebUI: [cyan]{_webui_display_url(webui_url)}[/cyan]")
gateway_health_url = _gateway_health_url(
runtime_config.gateway.host,
effective_gateway_port,
@ -223,19 +265,45 @@ def webui(
webui_ready = _webui_endpoint_reachable(webui_url)
if gateway_ready and webui_ready:
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
console.print(
"Restart the gateway if you need it to pick up local source changes: "
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(runtime)
else:
if not dev:
console.print(
"[yellow]This gateway is controlled by another foreground command. "
"Stop it from that terminal.[/yellow]"
"Restart the gateway if you need it to pick up local source changes: "
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
)
if not no_open:
_open_webui_browser(webui_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(runtime)
else:
console.print(
"[yellow]This gateway is controlled by another foreground command. "
"Stop it from that terminal.[/yellow]"
)
return
try:
assert dev_browser_url is not None
with run_webui_dev_server(
target_url=webui_dev_proxy_target(webui_url),
browser_url=dev_browser_url,
output=lambda message: console.print(f"[green]✓[/green] {message}"),
) as dev_server:
if not no_open:
_open_webui_browser(dev_browser_url, wait=False)
if runtime.status().running:
_attach_to_background_gateway(
runtime,
poll_hook=dev_server.ensure_running,
)
else:
_wait_with_existing_foreground_gateway(
runtime_config.gateway.host,
effective_gateway_port,
dev_server,
)
except WebUIDevError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
@ -252,6 +320,29 @@ def webui(
raise typer.Exit(1)
_print_webui_foreground_lifecycle(attached=False)
if dev_browser_url:
dev_proxy_target = webui_dev_proxy_target(webui_url)
try:
with run_webui_dev_server(
target_url=dev_proxy_target,
browser_url=dev_browser_url,
output=lambda message: console.print(f"[green]✓[/green] {message}"),
) as dev_server:
_run_gateway(
runtime_config,
port=effective_gateway_port,
open_browser_url=None if no_open else dev_browser_url,
open_browser_ready_url=f"{dev_proxy_target}/webui/bootstrap",
webui_static_dist=False,
webui_bundle_mode="skip",
unconfigured_provider_error=settings_setup_error,
webui_dev_server=dev_server,
)
except WebUIDevError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1) from exc
return
_run_gateway(
runtime_config,
port=effective_gateway_port,

View File

@ -2,6 +2,7 @@
import sys
import time
from collections.abc import Callable
from pathlib import Path
from typing import TYPE_CHECKING, Any
@ -424,11 +425,17 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
def _attach_to_background_gateway(runtime: "GatewayRuntime") -> None:
def _attach_to_background_gateway(
runtime: "GatewayRuntime",
*,
poll_hook: Callable[[], None] | None = None,
) -> None:
"""Keep a foreground WebUI command attached to a managed gateway."""
_print_webui_foreground_lifecycle(attached=True)
try:
while runtime.status().running:
if poll_hook is not None:
poll_hook()
time.sleep(0.5)
except KeyboardInterrupt:
console.print("\n[yellow]Stopping nanobot...[/yellow]")

211
nanobot/webui/dev.py Normal file
View File

@ -0,0 +1,211 @@
"""Vite development-server lifecycle for the WebUI source checkout."""
from __future__ import annotations
import os
import shutil
import socket
import subprocess
import time
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from nanobot.webui.build import default_webui_source_dir, pick_webui_build_runner
WEBUI_DEV_HOST = "127.0.0.1"
WEBUI_DEV_PORT = 5173
class WebUIDevError(RuntimeError):
"""Raised when the local Vite development server cannot be started."""
@dataclass
class WebUIDevServer:
"""A running Vite development server owned by the foreground CLI."""
process: subprocess.Popen[Any]
def ensure_running(self) -> None:
"""Raise when Vite exits while the foreground command still owns it."""
if (returncode := self.process.poll()) is not None:
raise WebUIDevError(
f"WebUI development server exited unexpectedly (code {returncode})"
)
def stop(self, *, timeout_s: float = 5.0) -> None:
"""Stop and reap the direct Vite process."""
if self.process.poll() is not None:
return
self.process.terminate()
try:
self.process.wait(timeout=timeout_s)
return
except subprocess.TimeoutExpired:
pass
self.process.kill()
with suppress(subprocess.TimeoutExpired):
self.process.wait(timeout=2)
def webui_dev_browser_url(webui_url: str) -> str:
"""Move a configured WebUI URL to Vite while preserving its auth fragment."""
parsed = urlsplit(webui_url)
return urlunsplit(("http", f"{WEBUI_DEV_HOST}:{WEBUI_DEV_PORT}", parsed.path, "", parsed.fragment))
def webui_dev_proxy_target(webui_url: str) -> str:
"""Return the backend origin Vite should use for HTTP proxy requests."""
parsed = urlsplit(webui_url)
return urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
def _endpoint_reachable(host: str, port: int, *, timeout_s: float = 0.2) -> bool:
try:
with socket.create_connection((host, port), timeout=timeout_s):
return True
except OSError:
return False
def _runner_name(runner: str) -> str:
return Path(runner).stem.casefold()
def _ensure_vite_cli(
source_dir: Path,
*,
runner: str,
subprocess_run: Callable[..., subprocess.CompletedProcess[Any]],
output: Callable[[str], None] | None,
) -> Path:
vite_cli = source_dir / "node_modules" / "vite" / "bin" / "vite.js"
if vite_cli.is_file():
return vite_cli
if output is not None:
output(f"Installing WebUI development dependencies with `{runner}`...")
if _runner_name(runner) == "bun" and (source_dir / "bun.lock").is_file():
command = [runner, "install", "--frozen-lockfile"]
elif _runner_name(runner) == "npm" and (source_dir / "package-lock.json").is_file():
command = [runner, "ci"]
else:
command = [runner, "install"]
try:
subprocess_run(command, cwd=source_dir, check=True)
except subprocess.CalledProcessError as exc:
raise WebUIDevError(
f"frontend dependency install failed ({exc.returncode}): {' '.join(command)}"
) from exc
except OSError as exc:
raise WebUIDevError(f"frontend dependency install failed: {exc}") from exc
if not vite_cli.is_file():
raise WebUIDevError(
f"Vite was not installed under {source_dir}; run `cd webui && {runner} install`"
)
return vite_cli
def _vite_command(runner: str, vite_cli: Path) -> list[str]:
if node := shutil.which("node"):
return [node, str(vite_cli)]
if _runner_name(runner) == "bun":
return [runner, str(vite_cli)]
raise WebUIDevError("Node.js is required to run the WebUI development server")
def start_webui_dev_server(
*,
target_url: str,
browser_url: str,
source_dir: Path | None = None,
runner: str | None = None,
environ: Mapping[str, str] | None = None,
output: Callable[[str], None] | None = None,
timeout_s: float = 15.0,
popen: Callable[..., subprocess.Popen[Any]] = subprocess.Popen,
subprocess_run: Callable[..., subprocess.CompletedProcess[Any]] = subprocess.run,
endpoint_reachable: Callable[..., bool] = _endpoint_reachable,
sleep: Callable[[float], None] = time.sleep,
) -> WebUIDevServer:
"""Start Vite from a source checkout and wait until its listener is ready."""
resolved_source = source_dir or default_webui_source_dir()
if not (resolved_source / "package.json").is_file():
raise WebUIDevError(
"`nanobot webui --dev` requires a source checkout containing webui/package.json"
)
if endpoint_reachable(WEBUI_DEV_HOST, WEBUI_DEV_PORT):
raise WebUIDevError(
f"WebUI development port {WEBUI_DEV_PORT} is already in use; stop that process first"
)
command_runner = runner or pick_webui_build_runner()
if command_runner is None:
raise WebUIDevError(
"neither `bun` nor `npm` is available on PATH; install one to use WebUI dev mode"
)
vite_cli = _ensure_vite_cli(
resolved_source,
runner=command_runner,
subprocess_run=subprocess_run,
output=output,
)
command = _vite_command(command_runner, vite_cli)
child_env = dict(environ or os.environ)
child_env["NANOBOT_API_URL"] = target_url
try:
# Keep Vite in the foreground console group so Ctrl+C reaches both it
# and the gateway. Directly invoking Vite avoids a package-manager child.
process = popen(command, cwd=resolved_source, env=child_env)
except OSError as exc:
raise WebUIDevError(f"could not start the WebUI development server: {exc}") from exc
server = WebUIDevServer(process=process)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if process.poll() is not None:
raise WebUIDevError(
f"WebUI development server exited before it was ready (code {process.returncode})"
)
if endpoint_reachable(WEBUI_DEV_HOST, WEBUI_DEV_PORT):
if output is not None:
parsed_url = urlsplit(browser_url)
display_url = urlunsplit(
(parsed_url.scheme, parsed_url.netloc, parsed_url.path, "", "")
)
output(f"WebUI dev server: {display_url}")
return server
sleep(0.1)
server.stop()
raise WebUIDevError(
f"WebUI development server did not listen on {WEBUI_DEV_HOST}:{WEBUI_DEV_PORT} "
f"within {timeout_s:g}s"
)
@contextmanager
def run_webui_dev_server(
*,
target_url: str,
browser_url: str,
output: Callable[[str], None] | None = None,
) -> Generator[WebUIDevServer, None, None]:
"""Run a Vite sidecar for the duration of a foreground WebUI command."""
server = start_webui_dev_server(
target_url=target_url,
browser_url=browser_url,
output=output,
)
try:
yield server
finally:
server.stop()

View File

@ -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("{}")

175
tests/webui/test_dev.py Normal file
View File

@ -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]

View File

@ -40,7 +40,26 @@ python -m pip install -e .
> Editable installs intentionally **skip** the WebUI bundle step — Vite HMR is faster than rebuilding `dist/` on every change.
### 2. Enable the WebSocket channel
### 2. Start the gateway and Vite
From the repository root:
```bash
nanobot webui --dev
```
The command safely prepares the local WebSocket channel, starts both the gateway and Vite,
and opens `http://127.0.0.1:5173`. Vite proxies to the configured WebSocket channel and applies
frontend changes with HMR. Press Ctrl+C in that terminal to stop both processes.
Use `--no-open` to skip opening a browser. `--dev` is foreground-only and cannot be combined
with `--background`.
## Manual development setup
The two-terminal workflow remains available when you want to manage each process separately.
### 1. Enable the WebSocket channel
In `~/.nanobot/config.json`, merge:
@ -48,7 +67,7 @@ In `~/.nanobot/config.json`, merge:
{ "channels": { "websocket": { "enabled": true } } }
```
### 3. Start the gateway
### 2. Start the gateway
In one terminal:
@ -56,7 +75,7 @@ In one terminal:
nanobot gateway
```
### 4. Start the WebUI dev server
### 3. Start the WebUI dev server
In another terminal: