diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7322ca4ba..247170506 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -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 ` | Set the WebUI/WebSocket port | | `nanobot webui --gateway-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. diff --git a/nanobot/channels/weixin/state.py b/nanobot/channels/weixin/state.py index 42ac8b6d5..6bdac1e5f 100644 --- a/nanobot/channels/weixin/state.py +++ b/nanobot/channels/weixin/state.py @@ -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: diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py index 5648f9192..7c66717bb 100644 --- a/nanobot/cli/gateway_runtime.py +++ b/nanobot/cli/gateway_runtime.py @@ -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 diff --git a/nanobot/cli/webui.py b/nanobot/cli/webui.py index b06e940c9..e83b29c90 100644 --- a/nanobot/cli/webui.py +++ b/nanobot/cli/webui.py @@ -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, diff --git a/nanobot/cli/webui_support.py b/nanobot/cli/webui_support.py index 58432d6a1..48a3fa92f 100644 --- a/nanobot/cli/webui_support.py +++ b/nanobot/cli/webui_support.py @@ -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]") diff --git a/nanobot/webui/dev.py b/nanobot/webui/dev.py new file mode 100644 index 000000000..1baf40e92 --- /dev/null +++ b/nanobot/webui/dev.py @@ -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() diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index bc4bc6a70..510d59164 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -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=" 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("{}") diff --git a/tests/webui/test_dev.py b/tests/webui/test_dev.py new file mode 100644 index 000000000..dd522c654 --- /dev/null +++ b/tests/webui/test_dev.py @@ -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] diff --git a/webui/README.md b/webui/README.md index 998ea02b2..57becacd2 100644 --- a/webui/README.md +++ b/webui/README.md @@ -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: