The dashboard becomes a machine-level management surface with one write-target selector, replacing per-profile dashboard fragmentation. Backend: - profile param (query or body) on /api/config (get/put/raw), /api/env (get/put/delete/reveal), /api/mcp/servers (list/add/remove/test/enabled), /api/mcp/catalog (list/install), /api/model/info, /api/model/set — all scoped through the existing _profile_scope() context manager - model/set restructured: expensive-model warning (await) runs before the scope; the config write runs sync inside the scope in a worker thread - MCP catalog installs + git-bootstrap entries spawn 'hermes -p <profile>' - chat PTY: ?profile= on /api/pty points the child's HERMES_HOME at the profile dir (its own gateway subprocess, config/skills/memory/state.db all profile-bound); in-process gateway attach skipped when scoped CLI launch unification: - '<profile> dashboard' routes to the machine dashboard: attach (open browser at ?profile=) when one is listening, else re-exec pinned to the default profile with --open-profile preselecting the launcher - --isolated preserves the old dedicated per-profile server behavior - start_server(initial_profile=...) appends ?profile= to the auto-open URL Frontend: - ProfileProvider + sidebar ProfileSwitcher: ONE global selector, URL- persisted (?profile=), mirrored into fetchJSON which auto-appends the param to the scoped endpoint families (explicit params win) - app-wide amber banner names the managed profile - SkillsPage's page-local selector (from the skills-scoping PR) folded into the global context — single source of truth - ChatPage threads the scope into the PTY WS URL; switching profiles remounts the terminal into a fresh scoped session Omitted profile keeps legacy behavior everywhere.
115 lines
4.4 KiB
Python
115 lines
4.4 KiB
Python
"""Tests for the unified profile→machine dashboard launch routing.
|
|
|
|
`<profile> dashboard` routes to ONE machine-level dashboard instead of
|
|
spawning a per-profile server: attach (open browser at ?profile=) when one
|
|
is already listening, else re-exec as the machine dashboard with the
|
|
launching profile preselected. `--isolated` opts out.
|
|
"""
|
|
import sys
|
|
import types
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def main_mod():
|
|
import hermes_cli.main as main_mod
|
|
return main_mod
|
|
|
|
|
|
def _args(**kw):
|
|
defaults = dict(
|
|
status=False, stop=False, host="127.0.0.1", port=9119,
|
|
no_open=True, insecure=False, skip_build=False,
|
|
isolated=False, open_profile="",
|
|
)
|
|
defaults.update(kw)
|
|
return types.SimpleNamespace(**defaults)
|
|
|
|
|
|
class TestUnifiedDashboardRouting:
|
|
def test_profile_launch_attaches_to_running_dashboard(self, main_mod, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
|
)
|
|
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: True)
|
|
execs = []
|
|
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
|
|
|
with pytest.raises(SystemExit) as exc:
|
|
main_mod.cmd_dashboard(_args())
|
|
assert exc.value.code == 0
|
|
assert execs == [] # attached, never re-exec'd
|
|
|
|
def test_profile_launch_reexecs_machine_dashboard(self, main_mod, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
|
)
|
|
monkeypatch.setattr(main_mod, "_dashboard_listening", lambda host, port: False)
|
|
execs = []
|
|
|
|
def fake_exec(exe, argv, env):
|
|
execs.append((exe, argv, env))
|
|
raise SystemExit(0) # execvpe never returns
|
|
|
|
monkeypatch.setattr(main_mod.os, "execvpe", fake_exec)
|
|
|
|
with pytest.raises(SystemExit):
|
|
main_mod.cmd_dashboard(_args())
|
|
|
|
assert len(execs) == 1
|
|
exe, argv, env = execs[0]
|
|
assert exe == sys.executable
|
|
# Pinned to the default profile + launching profile preselected.
|
|
assert "-p" in argv and argv[argv.index("-p") + 1] == "default"
|
|
assert "--open-profile" in argv
|
|
assert argv[argv.index("--open-profile") + 1] == "worker_x"
|
|
# Profile HERMES_HOME dropped so the child binds the machine root.
|
|
assert "HERMES_HOME" not in env
|
|
|
|
def test_isolated_flag_skips_routing(self, main_mod, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
|
)
|
|
listening_calls = []
|
|
monkeypatch.setattr(
|
|
main_mod, "_dashboard_listening",
|
|
lambda host, port: listening_calls.append(1) or True,
|
|
)
|
|
# With --isolated the routing block is skipped entirely; the command
|
|
# proceeds to dependency checks. Make the first post-routing step
|
|
# bail so the test doesn't actually start a server.
|
|
monkeypatch.setitem(sys.modules, "fastapi", None)
|
|
|
|
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
|
main_mod.cmd_dashboard(_args(isolated=True))
|
|
assert listening_calls == []
|
|
|
|
def test_default_profile_launch_skips_routing(self, main_mod, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles.get_active_profile_name", lambda: "default"
|
|
)
|
|
listening_calls = []
|
|
monkeypatch.setattr(
|
|
main_mod, "_dashboard_listening",
|
|
lambda host, port: listening_calls.append(1) or True,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "fastapi", None)
|
|
|
|
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
|
main_mod.cmd_dashboard(_args())
|
|
assert listening_calls == []
|
|
|
|
def test_reexec_child_does_not_reroute(self, main_mod, monkeypatch):
|
|
"""The re-exec'd child carries --open-profile; the guard must treat
|
|
that as 'already routed' and never re-exec again (no exec loop)."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.profiles.get_active_profile_name", lambda: "worker_x"
|
|
)
|
|
execs = []
|
|
monkeypatch.setattr(main_mod.os, "execvpe", lambda *a, **k: execs.append(a))
|
|
monkeypatch.setitem(sys.modules, "fastapi", None)
|
|
|
|
with pytest.raises((SystemExit, AttributeError, ImportError, TypeError)):
|
|
main_mod.cmd_dashboard(_args(open_profile="worker_x"))
|
|
assert execs == []
|