Files
nanobot/tests/cli/test_tui_launcher.py
T

724 lines
24 KiB
Python
Raw Normal View History

import hashlib
import io
import subprocess
import zipfile
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,
_download_release_tui,
_ensure_gateway,
_initial_tui_chat_id,
_initial_tui_workspace,
_resolve_source_tui_command,
_resolve_tui_command,
_websocket_chat_id,
2026-08-16 12:17:46 +08:00
launch_tui,
)
2026-08-16 12:17:46 +08:00
from nanobot.config.schema import Config, ModelPresetConfig
def _release_archive(
asset: str,
*,
binary: bytes = b"native-tui",
omit: str | None = None,
) -> tuple[bytes, bytes]:
files = {
asset: binary,
**{name: f"contents of {name}\n".encode() for name in tui_launcher._TUI_RELEASE_FILES},
}
if omit:
files.pop(omit)
manifest = "".join(
f"{hashlib.sha256(content).hexdigest()} {name}\n" for name, content in files.items()
).encode()
files["MANIFEST.sha256"] = manifest
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for name, content in files.items():
archive.writestr(name, content)
payload = output.getvalue()
archive_name = f"{asset}.zip"
checksum = f"{hashlib.sha256(payload).hexdigest()} {archive_name}\n".encode()
return payload, checksum
@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_default_tui_starts_fresh_but_explicit_session_wins() -> None:
assert _initial_tui_chat_id(None) is None
assert _initial_tui_chat_id("websocket:chosen") == "chosen"
def test_default_tui_workspace_is_the_launch_directory(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
launch_directory = tmp_path / "project"
override = tmp_path / "override"
launch_directory.mkdir()
monkeypatch.chdir(launch_directory)
assert _initial_tui_workspace(None) == launch_directory.resolve()
assert _initial_tui_workspace(str(override)) == override.resolve()
2026-08-16 12:17:46 +08:00
def test_launcher_passes_the_canonical_model_preset_to_the_tui(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
2026-08-18 16:03:15 +08:00
config = Config(
channels={"websocket": {"tokenIssueSecret": "bootstrap-secret"}},
)
2026-08-16 12:17:46 +08:00
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
config.agents.defaults.model_preset = "Deep Research"
captured: dict[str, str] = {}
2026-08-18 16:03:15 +08:00
events: list[str] = []
2026-08-16 22:45:09 +08:00
released: list[bool] = []
class FakeLease:
2026-08-18 16:03:15 +08:00
def release(self, *, wait_for_stop: bool = True) -> None:
assert wait_for_stop is False
2026-08-16 22:45:09 +08:00
released.append(True)
2026-08-16 12:17:46 +08:00
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
2026-08-18 16:03:15 +08:00
def ensure_gateway(*args: object, **kwargs: object) -> SimpleNamespace:
assert events == ["spawned"]
assert kwargs["wait_until_ready"] is False
return SimpleNamespace(
2026-08-16 12:17:46 +08:00
base_url="http://127.0.0.1:8765",
2026-08-16 22:45:09 +08:00
lease=FakeLease(),
2026-08-18 16:03:15 +08:00
)
2026-08-16 12:17:46 +08:00
2026-08-18 16:03:15 +08:00
monkeypatch.setattr("nanobot.cli.tui_launcher._ensure_gateway", ensure_gateway)
class FakeProcess:
def wait(self) -> int:
events.append("waited")
return 0
def popen(command: list[str], *, env: dict[str, str]) -> FakeProcess:
2026-08-16 12:17:46 +08:00
assert command == ["nanobot-tui"]
captured.update(env)
2026-08-18 16:03:15 +08:00
events.append("spawned")
return FakeProcess()
2026-08-16 12:17:46 +08:00
2026-08-18 16:03:15 +08:00
monkeypatch.setattr("nanobot.cli.tui_launcher.subprocess.Popen", popen)
2026-08-16 12:17:46 +08:00
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 captured["NANOBOT_TUI_WORKSPACE"] == str(Path.cwd().resolve())
2026-08-18 16:03:15 +08:00
assert captured["NANOBOT_TUI_BOOTSTRAP_URL"] == (
"http://127.0.0.1:8765/webui/bootstrap"
)
assert captured["NANOBOT_TUI_BOOTSTRAP_SECRET"] == "bootstrap-secret"
assert "NANOBOT_TUI_WS_URL" not in captured
assert "NANOBOT_TUI_API_TOKEN" not in captured
assert "NANOBOT_TUI_CHAT_ID" not in captured
assert "NANOBOT_TUI_STATE_PATH" not in captured
2026-08-18 16:03:15 +08:00
assert events == ["spawned", "waited"]
2026-08-16 22:45:09 +08:00
assert released == [True]
2026-08-16 12:17:46 +08:00
2026-08-18 16:03:15 +08:00
def test_launcher_terminates_the_tui_when_gateway_start_fails(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
terminated: list[bool] = []
class FakeProcess:
def poll(self) -> None:
return None
def terminate(self) -> None:
terminated.append(True)
def wait(self, timeout: float | None = None) -> int:
assert timeout == 5
return 1
def fail_gateway(*args: object, **kwargs: object) -> None:
raise RuntimeError("gateway failed")
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
monkeypatch.setattr(
"nanobot.cli.tui_launcher.subprocess.Popen",
lambda *args, **kwargs: FakeProcess(),
)
monkeypatch.setattr("nanobot.cli.tui_launcher._ensure_gateway", fail_gateway)
with pytest.raises(RuntimeError, match="gateway failed"):
launch_tui(
config,
config_path=tmp_path / "config.json",
workspace_override=None,
session_id=None,
theme="dark",
)
assert terminated == [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:
asset = "nanobot-tui-linux-x64"
binary = b"native-tui"
archive, checksum = _release_archive(asset, binary=binary)
downloads: list[str] = []
def read_asset(url: str, *, max_bytes: int) -> bytes:
downloads.append(url)
return checksum if url.endswith(".sha256") else archive
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(asset)
assert target == tmp_path / "bin" / "tui" / "9.9.9" / asset
assert target.read_bytes() == binary
for name in (*tui_launcher._TUI_RELEASE_FILES, "MANIFEST.sha256"):
assert (target.parent / name).is_file()
assert len(downloads) == 2
assert _download_release_tui(asset) == 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"
asset = "nanobot-tui-linux-x64"
archive, checksum = _release_archive(asset, binary=binary)
downloads: list[str] = []
def read_asset(url: str, *, max_bytes: int) -> bytes:
downloads.append(url)
return checksum if url.endswith(".sha256") else archive
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(asset)
assert target is not None
target.write_bytes(b"corrupted")
assert _download_release_tui(asset) == target
assert target.read_bytes() == binary
assert len(downloads) == 4
def test_release_tui_replaces_corrupted_cached_notices(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
asset = "nanobot-tui-linux-x64"
archive, checksum = _release_archive(asset)
downloads: list[str] = []
def read_asset(url: str, *, max_bytes: int) -> bytes:
downloads.append(url)
return checksum if url.endswith(".sha256") else archive
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(asset)
assert target is not None
notices = target.parent / "THIRD_PARTY_NOTICES.txt"
notices.write_text("corrupted", encoding="utf-8")
assert _download_release_tui(asset) == target
assert notices.read_bytes() == b"contents of THIRD_PARTY_NOTICES.txt\n"
assert len(downloads) == 4
def test_release_tui_rejects_bad_checksum(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
asset = "nanobot-tui-linux-x64"
archive, _checksum = _release_archive(asset)
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: (
f"{'0' * 64} {asset}.zip\n".encode() if url.endswith(".sha256") else archive
),
)
with pytest.raises(TuiUnavailableError, match="checksum"):
_download_release_tui(asset)
def test_release_tui_rejects_an_archive_without_required_notices(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
asset = "nanobot-tui-linux-x64"
archive, checksum = _release_archive(asset, omit="THIRD_PARTY_NOTICES.txt")
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: checksum if url.endswith(".sha256") else archive,
)
with pytest.raises(TuiUnavailableError, match="archive is incomplete"):
_download_release_tui(asset)
def test_release_tui_rejects_an_empty_required_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
asset = "nanobot-tui-linux-x64"
archive, _checksum = _release_archive(asset)
source = io.BytesIO(archive)
output = io.BytesIO()
with zipfile.ZipFile(source) as original, zipfile.ZipFile(output, "w") as rebuilt:
for entry in original.infolist():
content = b"" if entry.filename == "SOURCE_OFFER.md" else original.read(entry)
rebuilt.writestr(entry.filename, content)
payload = output.getvalue()
checksum = f"{hashlib.sha256(payload).hexdigest()} {asset}.zip\n".encode()
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: checksum if url.endswith(".sha256") else payload,
)
with pytest.raises(TuiUnavailableError, match="invalid size"):
_download_release_tui(asset)
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"
2026-08-18 16:03:15 +08:00
def test_gateway_reuse_can_return_before_the_webui_endpoint_is_ready(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
class FakeRuntime:
def __init__(self, *, paths: object) -> None:
self.paths = paths
def status(self) -> SimpleNamespace:
return SimpleNamespace(running=True, port=config.gateway.port)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
monkeypatch.setattr("nanobot.cli.tui_launcher._webui_endpoint_reachable", lambda _url: False)
gateway = _ensure_gateway(
config,
config_path=tmp_path / "config.json",
workspace_override=None,
wait_until_ready=False,
)
assert gateway.base_url == "http://127.0.0.1:8765"
assert gateway.lease is not None
2026-08-16 22:45:09 +08:00
def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config = Config()
started = False
2026-08-16 22:45:09 +08:00
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)
2026-08-16 22:45:09 +08:00
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
2026-08-16 22:45:09 +08:00
assert gateway.lease is not None
assert gateway.lease.release() is True
assert stopped is True