Files
nanobot/tests/cli/test_tui_launcher.py
T

559 lines
18 KiB
Python

import hashlib
import subprocess
from pathlib import Path
from types import SimpleNamespace
import pytest
import typer
import nanobot.cli.tui_launcher as tui_launcher
from nanobot.cli.agent import agent
from nanobot.cli.tui_launcher import (
TuiSessionError,
TuiUnavailableError,
_authenticated_ws_url,
_download_release_tui,
_ensure_gateway,
_initial_tui_chat_id,
_read_tui_chat_id,
_resolve_source_tui_command,
_resolve_tui_command,
_websocket_chat_id,
launch_tui,
)
from nanobot.config.schema import Config, ModelPresetConfig
def test_authenticated_ws_url_preserves_existing_query(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("nanobot.cli.tui_launcher.os.getpid", lambda: 42)
url = _authenticated_ws_url(
{"ws_url": "ws://127.0.0.1:8765/ws?mode=local", "token": "a b"}
)
assert url == "ws://127.0.0.1:8765/ws?mode=local&token=a+b&client_id=tui-42"
@pytest.mark.parametrize(
("session_id", "expected"),
[
("websocket:abc", "abc"),
("abc", "abc"),
],
)
def test_websocket_chat_id(session_id: str, expected: str | None) -> None:
assert _websocket_chat_id(session_id) == expected
def test_native_tui_rejects_a_session_owned_by_another_channel() -> None:
with pytest.raises(TuiSessionError, match="only WebSocket sessions"):
_websocket_chat_id("telegram:123")
def test_tui_chat_state_is_optional_and_validated(tmp_path: Path) -> None:
path = tmp_path / "tui" / "state.json"
assert _read_tui_chat_id(path) is None
path.parent.mkdir()
path.write_text('{"schema_version": 1, "chat_id": "saved-chat"}', encoding="utf-8")
assert _read_tui_chat_id(path) == "saved-chat"
path.write_text('{"chat_id": "bad\\nchat"}', encoding="utf-8")
assert _read_tui_chat_id(path) is None
def test_default_tui_resumes_but_explicit_session_wins(tmp_path: Path) -> None:
path = tmp_path / "tui" / "state.json"
path.parent.mkdir()
path.write_text('{"chat_id": "saved-chat"}', encoding="utf-8")
assert _initial_tui_chat_id(None, path) == "saved-chat"
assert _initial_tui_chat_id("websocket:chosen", path) == "chosen"
path.unlink()
assert _initial_tui_chat_id(None, path) is None
def test_launcher_passes_the_canonical_model_preset_to_the_tui(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
config.agents.defaults.model_preset = "Deep Research"
captured: dict[str, str] = {}
released: list[bool] = []
class FakeLease:
def release(self) -> None:
released.append(True)
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
monkeypatch.setattr(
"nanobot.cli.tui_launcher._ensure_gateway",
lambda *args, **kwargs: SimpleNamespace(
base_url="http://127.0.0.1:8765",
lease=FakeLease(),
),
)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._fetch_bootstrap",
lambda *args, **kwargs: {
"ws_url": "ws://127.0.0.1:8765/ws",
"token": "socket-token",
"api_token": "api-token",
},
)
def run(command: list[str], *, env: dict[str, str], check: bool) -> subprocess.CompletedProcess:
assert command == ["nanobot-tui"]
assert check is False
captured.update(env)
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", run)
result = launch_tui(
config,
config_path=tmp_path / "config.json",
workspace_override=None,
session_id=None,
theme="auto",
)
assert result == 0
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
assert "NANOBOT_TUI_CHAT_ID" not in captured
assert released == [True]
def test_explicit_tui_binary_must_exist(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
missing = tmp_path / "missing"
monkeypatch.setenv("NANOBOT_TUI_BIN", str(missing))
with pytest.raises(TuiUnavailableError, match="does not exist"):
_resolve_tui_command()
def test_windows_arm64_fails_instead_of_using_the_classic_prompt(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("NANOBOT_TUI_BIN", raising=False)
monkeypatch.setattr("nanobot.cli.tui_launcher.platform.system", lambda: "Windows")
monkeypatch.setattr("nanobot.cli.tui_launcher.platform.machine", lambda: "ARM64")
with pytest.raises(TuiUnavailableError, match="Windows ARM64"):
_resolve_tui_command()
def test_source_checkout_does_not_fall_back_to_a_release_tui_without_bun(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = tmp_path / "tui"
source_dir.mkdir()
monkeypatch.delenv("NANOBOT_TUI_BIN", raising=False)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._source_checkout_tui_dir",
lambda: source_dir,
)
monkeypatch.setattr("nanobot.cli.tui_launcher.shutil.which", lambda _name: None)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._download_release_tui",
lambda _asset: pytest.fail("a source checkout must not download a release TUI"),
)
with pytest.raises(TuiUnavailableError, match="source checkout requires Bun"):
_resolve_tui_command()
def test_source_checkout_requires_project_and_tui_markers(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
project_root = tmp_path / "project"
module_path = project_root / "nanobot" / "cli" / "tui_launcher.py"
source_dir = project_root / "tui"
module_path.parent.mkdir(parents=True)
source_dir.mkdir()
monkeypatch.setattr(tui_launcher, "__file__", str(module_path))
assert tui_launcher._source_checkout_tui_dir() is None
(source_dir / "package.json").write_text("{}", encoding="utf-8")
assert tui_launcher._source_checkout_tui_dir() is None
(project_root / "pyproject.toml").write_text("[project]\n", encoding="utf-8")
assert tui_launcher._source_checkout_tui_dir() == source_dir
def test_interactive_agent_uses_native_tui(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
config_path = tmp_path / "config.json"
launched: dict[str, object] = {}
def launch(*args: object, **kwargs: object) -> int:
launched["args"] = args
launched["kwargs"] = kwargs
return 0
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
monkeypatch.setattr("nanobot.cli.tui_launcher.launch_tui", launch)
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: True))
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
agent(
message=None,
session_id="websocket:terminal-chat",
workspace=None,
config=None,
markdown=True,
logs=False,
classic=False,
theme="light",
)
assert launched["args"] == (config,)
assert launched["kwargs"] == {
"config_path": config_path,
"workspace_override": None,
"session_id": "websocket:terminal-chat",
"theme": "light",
}
def test_interactive_agent_does_not_silently_fall_back(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
output: list[str] = []
def unavailable(*_args: object, **_kwargs: object) -> int:
raise TuiUnavailableError("missing sidecar")
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: config)
monkeypatch.setattr("nanobot.cli.agent.console.print", lambda value: output.append(value))
monkeypatch.setattr("nanobot.cli.tui_launcher.launch_tui", unavailable)
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: tmp_path / "config.json")
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: True))
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
with pytest.raises(typer.Exit) as exc_info:
agent(
message=None,
session_id=None,
workspace=None,
config=None,
markdown=True,
logs=False,
classic=False,
theme="auto",
)
assert exc_info.value.exit_code == 1
assert output == [
"[red]Native TUI unavailable: missing sidecar[/red]",
"[dim]Use `nanobot agent --classic` only if you want the old prompt.[/dim]",
]
def test_native_tui_rejects_a_classic_session_selector(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: Config())
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: tmp_path / "config.json")
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: True))
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
with pytest.raises(typer.BadParameter, match="only WebSocket sessions"):
agent(
message=None,
session_id="cli:direct",
workspace=None,
config=None,
markdown=True,
logs=False,
classic=False,
theme="auto",
)
def test_default_agent_does_not_fall_back_outside_a_terminal(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: Config())
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: False))
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
with pytest.raises(typer.BadParameter, match="requires an interactive terminal"):
agent(
message=None,
session_id=None,
workspace=None,
config=None,
markdown=True,
logs=False,
classic=False,
theme="auto",
)
@pytest.mark.parametrize(
("markdown", "logs", "option"),
[
(False, False, "--no-markdown"),
(True, True, "--logs"),
],
)
def test_classic_options_require_an_explicit_classic_prompt(
monkeypatch: pytest.MonkeyPatch,
markdown: bool,
logs: bool,
option: str,
) -> None:
monkeypatch.setattr("nanobot.cli.agent._load_runtime_config", lambda *_args: Config())
monkeypatch.setattr("nanobot.cli.agent.sys.stdin", SimpleNamespace(isatty=lambda: True))
monkeypatch.setattr("nanobot.cli.agent.sys.stdout", SimpleNamespace(isatty=lambda: True))
with pytest.raises(typer.BadParameter, match=f"{option} requires --classic"):
agent(
message=None,
session_id=None,
workspace=None,
config=None,
markdown=markdown,
logs=logs,
classic=False,
theme="auto",
)
def test_source_checkout_refreshes_locked_tui_dependencies(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = tmp_path / "tui"
source_dir.mkdir()
(source_dir / "node_modules" / "@opentui" / "core").mkdir(parents=True)
bun = str(tmp_path / "bun")
def install(command: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
assert command == [bun, "install", "--frozen-lockfile"]
assert kwargs["cwd"] == source_dir
return subprocess.CompletedProcess(command, 0, "", "")
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.run", install)
assert _resolve_source_tui_command(source_dir, bun) == [
bun,
str(source_dir / "src" / "index.ts"),
]
def test_source_checkout_fails_when_locked_dependencies_cannot_be_refreshed(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
source_dir = tmp_path / "tui"
source_dir.mkdir()
monkeypatch.setattr(
"nanobot.cli.tui_launcher.subprocess.run",
lambda *args, **kwargs: subprocess.CompletedProcess(args, 1, "", "lockfile mismatch"),
)
with pytest.raises(TuiUnavailableError, match="lockfile mismatch"):
_resolve_source_tui_command(source_dir, "bun")
def test_release_tui_is_verified_and_cached(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
binary = b"native-tui"
digest = hashlib.sha256(binary).hexdigest().encode()
downloads: list[str] = []
def read_asset(url: str, *, max_bytes: int) -> bytes:
downloads.append(url)
return digest if url.endswith(".sha256") else binary
monkeypatch.setattr("nanobot.cli.tui_launcher.__version__", "9.9.9")
monkeypatch.setattr("nanobot.cli.tui_launcher.get_data_dir", lambda: tmp_path)
monkeypatch.setattr("nanobot.cli.tui_launcher._read_release_asset", read_asset)
target = _download_release_tui("nanobot-tui-linux-x64")
assert target == tmp_path / "bin" / "tui" / "9.9.9" / "nanobot-tui-linux-x64"
assert target.read_bytes() == binary
assert target.with_name(f"{target.name}.sha256").read_text().startswith(digest.decode())
assert len(downloads) == 2
assert _download_release_tui("nanobot-tui-linux-x64") == target
assert len(downloads) == 2
def test_release_tui_replaces_a_corrupted_cached_binary(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
binary = b"native-tui"
digest = hashlib.sha256(binary).hexdigest().encode()
asset = "nanobot-tui-linux-x64"
target = tmp_path / "bin" / "tui" / "9.9.9" / asset
target.parent.mkdir(parents=True)
target.write_bytes(b"corrupted")
target.with_name(f"{target.name}.sha256").write_text(
f"{hashlib.sha256(binary).hexdigest()} {asset}\n"
)
downloads: list[str] = []
def read_asset(url: str, *, max_bytes: int) -> bytes:
downloads.append(url)
return digest if url.endswith(".sha256") else binary
monkeypatch.setattr("nanobot.cli.tui_launcher.__version__", "9.9.9")
monkeypatch.setattr("nanobot.cli.tui_launcher.get_data_dir", lambda: tmp_path)
monkeypatch.setattr("nanobot.cli.tui_launcher._read_release_asset", read_asset)
assert _download_release_tui(asset) == target
assert target.read_bytes() == binary
assert len(downloads) == 2
def test_release_tui_rejects_bad_checksum(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.setattr("nanobot.cli.tui_launcher.__version__", "9.9.9")
monkeypatch.setattr("nanobot.cli.tui_launcher.get_data_dir", lambda: tmp_path)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._read_release_asset",
lambda url, *, max_bytes: b"0" * 64 if url.endswith(".sha256") else b"tampered",
)
with pytest.raises(TuiUnavailableError, match="checksum"):
_download_release_tui("nanobot-tui-linux-x64")
def test_gateway_reuse_requires_the_matching_managed_instance(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
workspace = tmp_path / "workspace"
workspace.mkdir()
config.agents.defaults.workspace = str(workspace)
class FakeRuntime:
def __init__(self, *, paths: object) -> None:
self.paths = paths
def status(self) -> SimpleNamespace:
return SimpleNamespace(running=False, port=None)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr("nanobot.cli.tui_launcher._webui_endpoint_reachable", lambda _url: True)
with pytest.raises(TuiUnavailableError, match="different nanobot instance"):
_ensure_gateway(
config,
config_path=tmp_path / "config.json",
workspace_override=str(workspace),
)
def test_gateway_reuses_the_matching_managed_instance(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
workspace = tmp_path / "workspace"
workspace.mkdir()
config.agents.defaults.workspace = str(workspace)
class FakeRuntime:
def __init__(self, *, paths: object) -> None:
self.paths = paths
def status(self) -> SimpleNamespace:
return SimpleNamespace(running=True, port=config.gateway.port)
def stop(self, *, timeout_s: int) -> None:
raise AssertionError(f"unowned gateway stopped with timeout {timeout_s}")
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr("nanobot.cli.tui_launcher._webui_endpoint_reachable", lambda _url: True)
gateway = _ensure_gateway(
config,
config_path=tmp_path / "config.json",
workspace_override=str(workspace),
)
assert gateway.base_url == "http://127.0.0.1:8765"
def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
started = False
stopped = False
class FakeRuntime:
def __init__(self, *, paths: object) -> None:
self.paths = paths
def status(self) -> SimpleNamespace:
return SimpleNamespace(
running=started,
port=config.gateway.port if started else None,
)
def start_background(self, _options: object) -> SimpleNamespace:
nonlocal started
started = True
return SimpleNamespace(
ok=True,
message="gateway_started",
status=SimpleNamespace(log_path=tmp_path / "gateway.log"),
)
def start_on_demand(self, options: object) -> SimpleNamespace:
from nanobot.gateway import GatewayClientLease
GatewayClientLease(self, kind="test-tui").mark_ephemeral()
return self.start_background(options)
def stop(self, *, timeout_s: int) -> SimpleNamespace:
nonlocal stopped
assert timeout_s == 20
stopped = True
return SimpleNamespace(ok=True, message="gateway_stopped")
_stop = stop
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.tui_launcher._webui_endpoint_reachable",
lambda _url: started,
)
gateway = _ensure_gateway(
config,
config_path=tmp_path / "config.json",
workspace_override=None,
)
assert gateway.base_url == "http://127.0.0.1:8765"
assert started is True
assert gateway.lease is not None
assert gateway.lease.release() is True
assert stopped is True