Files
NexusOS/tests/test_cli_packaging.py
T
Athena Kaminsky 1449280fcd feat(cli): add interactive TUI chat
Add a Textual chat interface with threaded SSE streaming, slash commands, interrupt handling, and bare nexus dispatch. Package it behind the tui extra, document usage, and cover command routing, dependencies, and headless interaction with tests.
2026-08-26 08:17:31 -05:00

186 lines
7.3 KiB
Python

from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def run_cli(tmp_path: Path, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
for key in tuple(env):
if key.startswith("NEXUS_"):
env.pop(key)
env["NEXUS_HOME"] = str(tmp_path / "state")
env["NEXUS_CONFIG_DIR"] = str(tmp_path / "config")
return subprocess.run(
[sys.executable, "-m", "nexusos_cli.cli", *args],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
timeout=30,
check=False,
)
def test_help_exposes_portable_command_tree(tmp_path):
result = run_cli(tmp_path, "--help")
assert result.returncode == 0, result.stderr
for command in ("init", "config", "provider", "doctor", "serve", "models", "chat", "monitor", "tui"):
assert command in result.stdout
assert "interactive TUI" in result.stdout or "TUI" in result.stdout
def test_bare_nexus_defaults_to_tui_command():
"""No subcommand → TUI entry (Hermes-style). Non-TTY exits 2 without launching."""
from unittest import mock
from nexusos_cli.cli import build_parser, cmd_tui
parser = build_parser()
args = parser.parse_args([])
assert args.command is None # filled in by main()
with mock.patch("sys.stdin.isatty", return_value=False), \
mock.patch("sys.stdout.isatty", return_value=False):
assert cmd_tui(args) == 2
def test_legacy_cli_spellings_remain_compatible():
from nexusos_cli.cli import _normalize_legacy_argv
assert _normalize_legacy_argv(["start", "-b"]) == ["start", "backend"]
assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"]
assert _normalize_legacy_argv(["logs", "-m", "--follow"]) == ["logs", "memory", "--follow"]
assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"]
# -f is --follow for `logs`, but --frontend for start/stop. Translating it
# for logs turned `logs -f` into a one-shot tail of the frontend log.
assert _normalize_legacy_argv(["logs", "-f"]) == ["logs", "-f"]
assert _normalize_legacy_argv(["logs", "-m", "-f"]) == ["logs", "memory", "-f"]
assert _normalize_legacy_argv(["start", "-f"]) == ["start", "frontend"]
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
assert _normalize_legacy_argv(["help"]) == ["--help"]
def test_init_uses_external_state_and_seeds_playbooks(tmp_path):
result = run_cli(tmp_path, "init", "--json")
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert Path(payload["state_dir"]) == (tmp_path / "state").resolve()
assert payload["seeded_playbooks"] == len(list((ROOT / "data" / "playbooks").glob("*.yaml")))
assert len(list((tmp_path / "state" / "data" / "playbooks").glob("*.yaml"))) > 0
def test_config_persists_validated_values(tmp_path):
set_result = run_cli(tmp_path, "config", "set", "backend_port", "8123", "--json")
assert set_result.returncode == 0, set_result.stderr
get_result = run_cli(tmp_path, "config", "get", "backend_port", "--json")
assert get_result.returncode == 0, get_result.stderr
assert json.loads(get_result.stdout)["backend_port"] == 8123
invalid = run_cli(tmp_path, "config", "set", "backend_port", "70000")
assert invalid.returncode == 2
assert "between 1 and 65535" in invalid.stderr
def test_remote_provider_configuration_is_explicit(tmp_path):
result = run_cli(
tmp_path, "provider", "use", "remote", "--url", "http://phone-lan:11434", "--json"
)
assert result.returncode == 0, result.stderr
payload = json.loads(result.stdout)
assert payload["provider"] == "ollama-remote"
assert payload["url"] == "http://phone-lan:11434"
values = json.loads((tmp_path / "config" / "config.json").read_text(encoding="utf-8"))
assert values["provider"] == "ollama-remote"
show = run_cli(tmp_path, "provider", "show", "--json")
assert show.returncode == 0, show.stderr
assert json.loads(show.stdout)["url"] == "http://phone-lan:11434"
def test_serve_rejects_invalid_ports_before_startup(tmp_path):
result = run_cli(tmp_path, "serve", "--port", "0")
assert result.returncode == 2
assert "between 1 and 65535" in result.stderr
def test_remote_provider_is_never_stopped_or_force_killed(monkeypatch):
from nexusos_cli import ncp
killed_ports = []
killed_patterns = []
monkeypatch.setattr(ncp.settings, "manage_ollama", False)
monkeypatch.setattr(ncp.settings, "ollama_host", "http://remote.test:11434")
monkeypatch.setattr(ncp, "kill_port", lambda port: killed_ports.append(port) or False)
monkeypatch.setattr(
ncp, "kill_matching", lambda patterns, force=False: killed_patterns.extend(patterns) or 0
)
ncp.stop_ollama()
ncp.cmd_kill()
assert 11434 not in killed_ports
assert "ollama serve" not in killed_patterns
def test_allow_lan_names_addresses_instead_of_disabling_the_host_check():
"""ALLOWED_HOSTS=* would switch TrustedHostMiddleware off entirely, and that
middleware is the DNS-rebinding defense for an unauthenticated API."""
from nexusos_cli.cli import _lan_hostnames
assert _lan_hostnames("192.168.1.20") == ["192.168.1.20"]
wildcard = _lan_hostnames("0.0.0.0")
assert wildcard, "a wildcard bind must resolve to concrete host names"
assert "*" not in wildcard
# IPv6 literals need to match a Host header written either way.
for name in wildcard:
if ":" in name and not name.startswith("["):
assert f"[{name}]" in wildcard
def test_empty_platform_dir_variables_do_not_put_state_in_the_cwd(tmp_path, monkeypatch):
"""os.getenv's default only fires when a variable is *unset*; an exported
but empty XDG_DATA_HOME / LOCALAPPDATA made Path("") == "." the base, so
state landed in whatever directory the command happened to run from.
Patching os.name to exercise the other platform's branch is not an option -
pathlib dispatches on it - so this checks the branch this host actually
takes."""
from synapse import nexus_config
if os.name == "nt":
monkeypatch.setenv("LOCALAPPDATA", "")
expected = Path.home() / "AppData" / "Local" / "NexusOS"
else:
monkeypatch.setenv("XDG_DATA_HOME", "")
expected = Path.home() / ".local/share" / "nexusos"
monkeypatch.delenv("NEXUS_HOME", raising=False)
monkeypatch.chdir(tmp_path)
resolved = nexus_config._user_dir("NEXUS_HOME", "NexusOS", "XDG_DATA_HOME", ".local/share")
assert resolved.is_absolute()
assert tmp_path.resolve() != resolved.parent
assert resolved == expected.resolve()
def test_config_set_warns_before_orphaning_the_database(tmp_path):
"""Repointing the DB does not move it - say so, or history looks deleted."""
assert run_cli(tmp_path, "init").returncode == 0
db = tmp_path / "state" / "data" / "memory.db"
db.parent.mkdir(parents=True, exist_ok=True)
db.write_bytes(b"SQLite format 3" + bytes(1))
result = run_cli(tmp_path, "config", "set", "memory_db", str(tmp_path / "elsewhere.db"), "--json")
assert result.returncode == 0, result.stderr
assert "warning" in json.loads(result.stdout)
same = run_cli(tmp_path, "config", "set", "memory_db", str(db), "--json")
assert "warning" not in json.loads(same.stdout)