Merge remote-tracking branch 'origin/main' into bb/gui
# Conflicts: # apps/dashboard/src/i18n/af.ts # apps/dashboard/src/i18n/de.ts # apps/dashboard/src/i18n/es.ts # apps/dashboard/src/i18n/fr.ts # apps/dashboard/src/i18n/ga.ts # apps/dashboard/src/i18n/hu.ts # apps/dashboard/src/i18n/it.ts # apps/dashboard/src/i18n/ja.ts # apps/dashboard/src/i18n/ko.ts # apps/dashboard/src/i18n/pt.ts # apps/dashboard/src/i18n/ru.ts # apps/dashboard/src/i18n/tr.ts # apps/dashboard/src/i18n/uk.ts # apps/dashboard/src/i18n/zh-hant.ts # gateway/config.py # hermes_cli/main.py # plugins/strike-freedom-cockpit/README.md # tui_gateway/server.py
This commit is contained in:
@@ -81,6 +81,81 @@ class TestLoadConfigDefaults:
|
||||
assert "max_turns" not in config
|
||||
|
||||
|
||||
class TestLoadConfigParseFailure:
|
||||
"""A YAML parse failure must NOT silently fall back to defaults.
|
||||
|
||||
Before issue #23570 this was a single ``print(...)`` that scrolled past
|
||||
on the first invocation — users saw aux-fallback misbehavior with no clue
|
||||
their config.yaml was being ignored. The helper must:
|
||||
* log at WARNING (so ``hermes logs`` surfaces it)
|
||||
* also write to stderr (so it's visible at startup even before
|
||||
``setup_logging()`` has wired up file handlers)
|
||||
* dedup on (path, mtime_ns, size) so concurrent loads don't spam
|
||||
* re-warn after the user edits the file (different mtime)
|
||||
"""
|
||||
|
||||
def test_logs_and_warns_on_parse_failure(self, tmp_path, caplog, capsys):
|
||||
# Reset the dedup cache so this test isn't affected by other tests
|
||||
# that may have warned about a different broken config.
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
(tmp_path / "config.yaml").write_text("\tbroken tab indent:\n")
|
||||
|
||||
import logging
|
||||
with caplog.at_level(logging.WARNING, logger="hermes_cli.config"):
|
||||
config = load_config()
|
||||
|
||||
# Falls back to defaults — confirms the silent-fallback we're warning about
|
||||
assert config["model"] == DEFAULT_CONFIG["model"]
|
||||
|
||||
# WARNING-level log was emitted with file path + reason
|
||||
assert any(
|
||||
str(tmp_path / "config.yaml") in rec.message
|
||||
and "Falling back to default config" in rec.message
|
||||
for rec in caplog.records
|
||||
), f"expected WARNING log, got: {[r.message for r in caplog.records]}"
|
||||
|
||||
# stderr also got a user-visible message (with the ⚠️ marker so it
|
||||
# stands out at hermes startup before logging is configured)
|
||||
captured = capsys.readouterr()
|
||||
assert "hermes config:" in captured.err
|
||||
assert str(tmp_path / "config.yaml") in captured.err
|
||||
|
||||
def test_dedup_on_repeated_load_same_file(self, tmp_path, capsys):
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
(tmp_path / "config.yaml").write_text("\tbroken:\n")
|
||||
|
||||
load_config()
|
||||
first = capsys.readouterr().err
|
||||
assert "hermes config:" in first
|
||||
|
||||
load_config()
|
||||
second = capsys.readouterr().err
|
||||
assert second == "", "second load should NOT re-warn (same file, same mtime)"
|
||||
|
||||
def test_rewarns_after_file_edit(self, tmp_path, capsys):
|
||||
import time
|
||||
from hermes_cli import config as cfg_mod
|
||||
cfg_mod._CONFIG_PARSE_WARNED.clear()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
(tmp_path / "config.yaml").write_text("\tbroken:\n")
|
||||
load_config()
|
||||
capsys.readouterr() # discard first warning
|
||||
|
||||
# Edit the file (still broken, but different content) — mtime changes
|
||||
time.sleep(0.05)
|
||||
(tmp_path / "config.yaml").write_text("\tstill broken differently:\n")
|
||||
load_config()
|
||||
after_edit = capsys.readouterr().err
|
||||
assert "hermes config:" in after_edit, "edited file should re-warn"
|
||||
|
||||
|
||||
class TestSaveAndLoadRoundtrip:
|
||||
def test_roundtrip(self, tmp_path):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
|
||||
|
||||
@@ -392,3 +392,13 @@ def test_run_slash_missing_required_arg_friendly_error(kanban_home):
|
||||
out = kc.run_slash("show")
|
||||
assert "/kanban show" in out
|
||||
assert "task_id" in out
|
||||
|
||||
|
||||
def test_run_slash_board_override_restores_prior_env(kanban_home, monkeypatch):
|
||||
kb.create_board("alpha")
|
||||
kb.create_board("beta")
|
||||
monkeypatch.setenv("HERMES_KANBAN_BOARD", "beta")
|
||||
|
||||
kc.run_slash("--board alpha list")
|
||||
|
||||
assert os.environ.get("HERMES_KANBAN_BOARD") == "beta"
|
||||
|
||||
@@ -510,10 +510,12 @@ def test_notify_sub_crud(kanban_home):
|
||||
tid = kb.create_task(conn, title="x")
|
||||
kb.add_notify_sub(
|
||||
conn, task_id=tid, platform="telegram", chat_id="123", user_id="u1",
|
||||
notifier_profile="default",
|
||||
)
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
assert len(subs) == 1
|
||||
assert subs[0]["platform"] == "telegram"
|
||||
assert subs[0]["notifier_profile"] == "default"
|
||||
# Duplicate add is a no-op.
|
||||
kb.add_notify_sub(
|
||||
conn, task_id=tid, platform="telegram", chat_id="123",
|
||||
@@ -568,6 +570,57 @@ def test_notify_cursor_advances(kanban_home):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_notify_claim_is_single_owner_and_rewindable(kanban_home):
|
||||
conn1 = kb.connect()
|
||||
conn2 = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn1, title="x", assignee="w")
|
||||
kb.add_notify_sub(conn1, task_id=tid, platform="telegram", chat_id="123")
|
||||
kb.complete_task(conn1, tid, result="ok")
|
||||
|
||||
old_cursor, claimed_cursor, events = kb.claim_unseen_events_for_sub(
|
||||
conn1,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
kinds=["completed", "blocked"],
|
||||
)
|
||||
assert old_cursor == 0
|
||||
assert claimed_cursor > old_cursor
|
||||
assert [ev.kind for ev in events] == ["completed"]
|
||||
|
||||
# A concurrent notifier instance sees the advanced cursor and cannot
|
||||
# claim/send the same event range.
|
||||
_, _, duplicate_events = kb.claim_unseen_events_for_sub(
|
||||
conn2,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
kinds=["completed", "blocked"],
|
||||
)
|
||||
assert duplicate_events == []
|
||||
|
||||
assert kb.rewind_notify_cursor(
|
||||
conn1,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
claimed_cursor=claimed_cursor,
|
||||
old_cursor=old_cursor,
|
||||
) is True
|
||||
_, retried_events = kb.unseen_events_for_sub(
|
||||
conn2,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="123",
|
||||
kinds=["completed", "blocked"],
|
||||
)
|
||||
assert [ev.kind for ev in retried_events] == ["completed"]
|
||||
finally:
|
||||
conn1.close()
|
||||
conn2.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GC + retention
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2691,6 +2744,48 @@ def test_create_task_skills_rejects_comma_embedded(kanban_home):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_create_task_skills_rejects_toolset_names(kanban_home):
|
||||
"""Toolset names belong in profile config, not per-task skills."""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
with pytest.raises(ValueError, match="toolset name"):
|
||||
kb.create_task(
|
||||
conn,
|
||||
title="bad toolset skill",
|
||||
assignee="x",
|
||||
skills=["web", "translation"],
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_create_task_skills_lists_all_toolset_typos(kanban_home):
|
||||
"""When several toolset names are passed, the error names every one.
|
||||
|
||||
Agents that confuse skills with toolsets usually pass several at once
|
||||
(``skills=["web", "browser", "terminal"]``). Listing only the first
|
||||
mistake forces serial fix-then-retry; listing all of them lets the
|
||||
caller correct in one round-trip.
|
||||
"""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
kb.create_task(
|
||||
conn,
|
||||
title="three bad",
|
||||
assignee="x",
|
||||
skills=["web", "browser", "terminal"],
|
||||
)
|
||||
msg = str(exc_info.value)
|
||||
assert "'web'" in msg
|
||||
assert "'browser'" in msg
|
||||
assert "'terminal'" in msg
|
||||
# Plural noun form when multiple toolsets are flagged.
|
||||
assert "are toolset names" in msg
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_default_spawn_appends_per_task_skills(kanban_home, monkeypatch):
|
||||
"""Dispatcher argv must carry one `--skills X` pair per task skill,
|
||||
in addition to the built-in kanban-worker."""
|
||||
@@ -3446,6 +3541,76 @@ def test_complete_accepts_cross_worker_card_when_linked_as_child(kanban_home):
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_can_retry_after_phantom_rejection(kanban_home):
|
||||
"""A worker that hits the hallucinated-card gate must be able to
|
||||
retry kanban_complete on the same task — both with a corrected
|
||||
created_cards list and with an empty list (the documented escape
|
||||
hatch). Regression test for #22923, where workers were believed to
|
||||
be unrecoverable after the first rejection.
|
||||
"""
|
||||
conn = kb.connect()
|
||||
try:
|
||||
# Two parallel completing tasks so we can exercise both retry
|
||||
# shapes without status interference.
|
||||
parent_a = kb.create_task(conn, title="retry-empty", assignee="alice")
|
||||
kb.claim_task(conn, parent_a)
|
||||
parent_b = kb.create_task(conn, title="retry-corrected", assignee="alice")
|
||||
kb.claim_task(conn, parent_b)
|
||||
real = kb.create_task(
|
||||
conn, title="real-child", assignee="x", created_by="alice",
|
||||
)
|
||||
|
||||
# First attempt: phantom in the list rejects, task stays running.
|
||||
with pytest.raises(kb.HallucinatedCardsError):
|
||||
kb.complete_task(
|
||||
conn, parent_a,
|
||||
summary="oops",
|
||||
created_cards=["t_phantomdeadbeef"],
|
||||
)
|
||||
assert kb.get_task(conn, parent_a).status == "running"
|
||||
|
||||
# Retry with [] (escape hatch): gate is skipped, completion lands.
|
||||
ok = kb.complete_task(
|
||||
conn, parent_a,
|
||||
summary="retry without claims",
|
||||
created_cards=[],
|
||||
)
|
||||
assert ok is True
|
||||
assert kb.get_task(conn, parent_a).status == "done"
|
||||
|
||||
# Same flow on parent_b, but recover via a corrected list rather
|
||||
# than the empty escape hatch.
|
||||
with pytest.raises(kb.HallucinatedCardsError):
|
||||
kb.complete_task(
|
||||
conn, parent_b,
|
||||
summary="oops",
|
||||
created_cards=[real, "t_anotherphantom"],
|
||||
)
|
||||
assert kb.get_task(conn, parent_b).status == "running"
|
||||
|
||||
ok = kb.complete_task(
|
||||
conn, parent_b,
|
||||
summary="retry with corrected list",
|
||||
created_cards=[real],
|
||||
)
|
||||
assert ok is True
|
||||
assert kb.get_task(conn, parent_b).status == "done"
|
||||
|
||||
# Both audit events landed; the eventual completion event is
|
||||
# also present on each task.
|
||||
for parent in (parent_a, parent_b):
|
||||
kinds = [
|
||||
r["kind"] for r in conn.execute(
|
||||
"SELECT kind FROM task_events WHERE task_id=? ORDER BY id",
|
||||
(parent,),
|
||||
)
|
||||
]
|
||||
assert kinds.count("completion_blocked_hallucination") == 1
|
||||
assert kinds.count("completed") == 1
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_complete_prose_scan_flags_nonexistent_ids(kanban_home):
|
||||
"""Successful completion whose summary references a ``t_<hex>`` id
|
||||
that doesn't resolve emits a ``suspected_hallucinated_references``
|
||||
|
||||
@@ -177,12 +177,9 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch):
|
||||
host = _kb._claimer_id().split(":", 1)[0]
|
||||
kb.claim_task(conn, t, claimer=f"{host}:worker")
|
||||
killed: list[int] = []
|
||||
state = {"alive": True}
|
||||
|
||||
def _signal(pid, sig):
|
||||
def _signal(_pid, sig):
|
||||
killed.append(sig)
|
||||
if sig == signal.SIGTERM:
|
||||
state["alive"] = False
|
||||
|
||||
kb._set_worker_pid(conn, t, 12345)
|
||||
# Rewind claim_expires so it looks stale.
|
||||
@@ -190,13 +187,96 @@ def test_stale_claim_reclaimed(kanban_home, monkeypatch):
|
||||
"UPDATE tasks SET claim_expires = ? WHERE id = ?",
|
||||
(int(time.time()) - 3600, t),
|
||||
)
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: state["alive"])
|
||||
# Worker PID has died — exactly the case ``release_stale_claims``
|
||||
# should still reclaim (post-#23025: live PIDs are now extended).
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
reclaimed = kb.release_stale_claims(conn, signal_fn=_signal)
|
||||
assert reclaimed == 1
|
||||
assert kb.get_task(conn, t).status == "ready"
|
||||
assert killed == [signal.SIGTERM]
|
||||
|
||||
|
||||
def test_stale_claim_with_live_pid_extends_instead_of_reclaiming(
|
||||
kanban_home, monkeypatch,
|
||||
):
|
||||
"""A stale-by-TTL claim whose worker PID is still alive should be
|
||||
extended, not reclaimed (#23025). Slow models can spend longer than
|
||||
``DEFAULT_CLAIM_TTL_SECONDS`` inside a single tool-free LLM call;
|
||||
killing those healthy workers produces a respawn loop with zero
|
||||
progress."""
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="a")
|
||||
host = _kb._claimer_id().split(":", 1)[0]
|
||||
kb.claim_task(conn, t, claimer=f"{host}:worker")
|
||||
kb._set_worker_pid(conn, t, 12345)
|
||||
|
||||
old_expires = int(time.time()) - 60
|
||||
conn.execute(
|
||||
"UPDATE tasks SET claim_expires = ? WHERE id = ?",
|
||||
(old_expires, t),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: True)
|
||||
killed: list[int] = []
|
||||
reclaimed = kb.release_stale_claims(
|
||||
conn, signal_fn=lambda _p, sig: killed.append(sig),
|
||||
)
|
||||
assert reclaimed == 0
|
||||
task = kb.get_task(conn, t)
|
||||
assert task.status == "running"
|
||||
assert task.claim_expires is not None
|
||||
assert task.claim_expires > old_expires
|
||||
assert killed == [] # live worker not killed
|
||||
|
||||
kinds = [
|
||||
r["kind"] for r in conn.execute(
|
||||
"SELECT kind FROM task_events WHERE task_id = ?", (t,),
|
||||
).fetchall()
|
||||
]
|
||||
assert "claim_extended" in kinds
|
||||
assert "reclaimed" not in kinds
|
||||
|
||||
|
||||
def test_stale_claim_reclaim_event_records_diagnostic_payload(
|
||||
kanban_home, monkeypatch,
|
||||
):
|
||||
"""``reclaimed`` events should carry claim_expires, last_heartbeat_at,
|
||||
and worker_pid so operators can diagnose why a claim went stale
|
||||
(#23025: previous payload only had ``stale_lock`` which gives no
|
||||
timing context)."""
|
||||
import json
|
||||
import hermes_cli.kanban_db as _kb
|
||||
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="a")
|
||||
host = _kb._claimer_id().split(":", 1)[0]
|
||||
kb.claim_task(conn, t, claimer=f"{host}:worker")
|
||||
kb._set_worker_pid(conn, t, 12345)
|
||||
old_expires = int(time.time()) - 3600
|
||||
hb_at = int(time.time()) - 1800
|
||||
conn.execute(
|
||||
"UPDATE tasks SET claim_expires = ?, last_heartbeat_at = ? "
|
||||
"WHERE id = ?",
|
||||
(old_expires, hb_at, t),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_kb, "_pid_alive", lambda _pid: False)
|
||||
kb.release_stale_claims(conn, signal_fn=lambda _p, _s: None)
|
||||
row = conn.execute(
|
||||
"SELECT payload FROM task_events "
|
||||
"WHERE task_id = ? AND kind = 'reclaimed'",
|
||||
(t,),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
payload = json.loads(row["payload"])
|
||||
assert payload["claim_expires"] == old_expires
|
||||
assert payload["last_heartbeat_at"] == hb_at
|
||||
assert payload["worker_pid"] == 12345
|
||||
assert payload["host_local"] is True
|
||||
|
||||
|
||||
def test_max_runtime_uses_current_run_start_after_retry(kanban_home):
|
||||
"""A retry should get a fresh max-runtime window.
|
||||
|
||||
@@ -605,6 +685,57 @@ def test_dispatch_spawn_failure_releases_claim(kanban_home, all_assignees_spawna
|
||||
assert kb.get_task(conn, t).claim_lock is None
|
||||
|
||||
|
||||
def test_dispatch_max_spawn_counts_existing_running_tasks(
|
||||
kanban_home, all_assignees_spawnable
|
||||
):
|
||||
"""max_spawn is a live concurrency cap, not a per-tick spawn cap.
|
||||
|
||||
Without counting tasks already in ``running``, every dispatcher tick can
|
||||
launch up to ``max_spawn`` more workers while previous workers are still
|
||||
alive. Long-running boards then accumulate unbounded worker subprocesses.
|
||||
"""
|
||||
spawns = []
|
||||
|
||||
def fake_spawn(task, workspace):
|
||||
spawns.append(task.id)
|
||||
|
||||
with kb.connect() as conn:
|
||||
running_a = kb.create_task(conn, title="running-a", assignee="alice")
|
||||
running_b = kb.create_task(conn, title="running-b", assignee="bob")
|
||||
ready = kb.create_task(conn, title="ready", assignee="carol")
|
||||
kb.claim_task(conn, running_a)
|
||||
kb.claim_task(conn, running_b)
|
||||
|
||||
res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
|
||||
|
||||
assert res.spawned == []
|
||||
assert spawns == []
|
||||
assert kb.get_task(conn, ready).status == "ready"
|
||||
|
||||
|
||||
def test_dispatch_max_spawn_fills_remaining_capacity(
|
||||
kanban_home, all_assignees_spawnable
|
||||
):
|
||||
"""When below cap, dispatch only fills available worker slots."""
|
||||
spawns = []
|
||||
|
||||
def fake_spawn(task, workspace):
|
||||
spawns.append(task.id)
|
||||
|
||||
with kb.connect() as conn:
|
||||
running = kb.create_task(conn, title="running", assignee="alice")
|
||||
ready_a = kb.create_task(conn, title="ready-a", assignee="bob")
|
||||
ready_b = kb.create_task(conn, title="ready-b", assignee="carol")
|
||||
kb.claim_task(conn, running)
|
||||
|
||||
res = kb.dispatch_once(conn, spawn_fn=fake_spawn, max_spawn=2)
|
||||
|
||||
assert len(res.spawned) == 1
|
||||
assert spawns == [ready_a]
|
||||
assert kb.get_task(conn, ready_a).status == "running"
|
||||
assert kb.get_task(conn, ready_b).status == "ready"
|
||||
|
||||
|
||||
def test_dispatch_reclaims_stale_before_spawning(kanban_home):
|
||||
with kb.connect() as conn:
|
||||
t = kb.create_task(conn, title="x", assignee="alice")
|
||||
@@ -1199,3 +1330,203 @@ def test_migrate_add_optional_columns_tolerates_concurrent_migration(kanban_home
|
||||
# Running migration on an already-migrated schema must not raise.
|
||||
kb._migrate_add_optional_columns(conn)
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dispatcher spawn invocation — _resolve_hermes_argv()
|
||||
#
|
||||
# Workers spawned by the dispatcher must use a `hermes` invocation that does
|
||||
# not depend on PATH being set up correctly. cron jobs, systemd User= services,
|
||||
# launchd jobs, and other detached processes routinely run with a stripped
|
||||
# $PATH that doesn't include the venv's bin/, so a bare `["hermes", ...]`
|
||||
# spawn fails with FileNotFoundError and the task gets stuck. The resolver
|
||||
# prefers the PATH shim (familiar `ps` output) but falls back to the module
|
||||
# form so the spawn keeps working when PATH is missing the shim.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_hermes_argv_prefers_path_shim(monkeypatch):
|
||||
"""When `hermes` is on PATH, use the shim — preserves familiar ps output."""
|
||||
import shutil
|
||||
import hermes_cli.kanban_db as kb
|
||||
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/local/bin/hermes")
|
||||
argv = kb._resolve_hermes_argv()
|
||||
assert argv == ["/usr/local/bin/hermes"]
|
||||
|
||||
|
||||
def test_resolve_hermes_argv_falls_back_to_module_form_when_no_path_shim(monkeypatch):
|
||||
"""When the shim is not on PATH, fall back to `python -m hermes_cli.main`.
|
||||
|
||||
Pins the correct module name (NOT `hermes` — there is no top-level
|
||||
`hermes` package). Regression for #23198: the original PR shipped
|
||||
`python -m hermes` which fails with `No module named hermes` on every
|
||||
invocation.
|
||||
"""
|
||||
import shutil
|
||||
import sys
|
||||
import hermes_cli.kanban_db as kb
|
||||
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
argv = kb._resolve_hermes_argv()
|
||||
assert argv == [sys.executable, "-m", "hermes_cli.main"]
|
||||
|
||||
|
||||
def test_resolve_hermes_argv_module_actually_runs():
|
||||
"""The fallback module name must be importable + runnable.
|
||||
|
||||
A unit test that pins the literal string is necessary but not
|
||||
sufficient — if `hermes_cli.main` ever loses `if __name__ == "__main__"`
|
||||
handling or its argparse setup, `python -m hermes_cli.main --version`
|
||||
would fail and so would every dispatcher spawn that hits the fallback.
|
||||
Run it as a real subprocess to catch that regression.
|
||||
"""
|
||||
import subprocess
|
||||
import sys
|
||||
import hermes_cli.kanban_db as kb
|
||||
import shutil
|
||||
import unittest.mock as mock
|
||||
|
||||
with mock.patch.object(shutil, "which", return_value=None):
|
||||
argv = kb._resolve_hermes_argv()
|
||||
r = subprocess.run(argv + ["--version"], capture_output=True, text=True, timeout=30)
|
||||
assert r.returncode == 0, (
|
||||
f"`{' '.join(argv)} --version` failed (rc={r.returncode}); "
|
||||
f"stderr={r.stderr[:200]!r}"
|
||||
)
|
||||
assert "Hermes Agent" in r.stdout, f"unexpected output: {r.stdout[:200]!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# task_age — guard against corrupt timestamp values
|
||||
#
|
||||
# The Task dataclass declares ``created_at: int`` but rows come from sqlite
|
||||
# without coercion at the boundary. A row that ever held a non-int (e.g. an
|
||||
# unsubstituted ``'%s'`` from a logged format string, ``None``, an arbitrary
|
||||
# string, or a float-as-string) used to crash ``task_age`` with ``ValueError``
|
||||
# and turn ``GET /api/plugins/kanban/board`` into a 500 because the dashboard
|
||||
# calls ``task_age`` unguarded for every task in the response.
|
||||
#
|
||||
# After the fix, ``_safe_int`` returns ``None`` on bad input and ``task_age``
|
||||
# degrades gracefully (per-field ``None`` rather than a hard crash).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_task(**overrides) -> "kb.Task":
|
||||
"""Minimal Task with all required fields filled in. Override anything."""
|
||||
defaults = dict(
|
||||
id="t_age",
|
||||
title="x",
|
||||
body=None,
|
||||
assignee=None,
|
||||
status="ready",
|
||||
priority=0,
|
||||
created_by=None,
|
||||
created_at=0,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="scratch",
|
||||
workspace_path=None,
|
||||
claim_lock=None,
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return kb.Task(**defaults)
|
||||
|
||||
|
||||
def test_safe_int_accepts_int_and_int_string():
|
||||
"""Sanity: well-typed values pass through."""
|
||||
assert kb._safe_int(0) == 0
|
||||
assert kb._safe_int(1700000000) == 1700000000
|
||||
assert kb._safe_int("1700000000") == 1700000000
|
||||
|
||||
|
||||
def test_safe_int_returns_none_on_corrupt_inputs():
|
||||
"""All the failure modes that used to crash task_age."""
|
||||
# None — common when the column was never written
|
||||
assert kb._safe_int(None) is None
|
||||
# Unsubstituted format string — the literal case the PR title cites
|
||||
assert kb._safe_int("%s") is None
|
||||
# Arbitrary non-numeric strings
|
||||
assert kb._safe_int("abc") is None
|
||||
assert kb._safe_int("") is None
|
||||
# Float-ish strings: int("1.5") raises ValueError too — caller wants None.
|
||||
assert kb._safe_int("1.5") is None
|
||||
# Random object — covered by TypeError branch
|
||||
assert kb._safe_int(object()) is None
|
||||
|
||||
|
||||
def test_task_age_handles_corrupt_created_at():
|
||||
"""Pre-fix this raised ValueError and 500'd /api/plugins/kanban/board."""
|
||||
t = _make_task(created_at="%s")
|
||||
age = kb.task_age(t)
|
||||
assert age["created_age_seconds"] is None
|
||||
assert age["started_age_seconds"] is None
|
||||
assert age["time_to_complete_seconds"] is None
|
||||
|
||||
|
||||
def test_task_age_handles_corrupt_started_and_completed():
|
||||
"""All three timestamp fields share the same _safe_int treatment."""
|
||||
t = _make_task(
|
||||
created_at=1700000000,
|
||||
started_at="garbage",
|
||||
completed_at=None,
|
||||
)
|
||||
age = kb.task_age(t)
|
||||
assert isinstance(age["created_age_seconds"], int)
|
||||
assert age["started_age_seconds"] is None
|
||||
assert age["time_to_complete_seconds"] is None
|
||||
|
||||
|
||||
def test_task_age_well_formed_task():
|
||||
"""Regression: the safe-int path must not change behavior for normal data."""
|
||||
import time
|
||||
now = int(time.time())
|
||||
t = _make_task(
|
||||
created_at=now - 60,
|
||||
started_at=now - 30,
|
||||
completed_at=now,
|
||||
)
|
||||
age = kb.task_age(t)
|
||||
assert 55 <= age["created_age_seconds"] <= 65
|
||||
assert 25 <= age["started_age_seconds"] <= 35
|
||||
assert 25 <= age["time_to_complete_seconds"] <= 35
|
||||
|
||||
|
||||
def test_task_dict_survives_corrupt_created_at(tmp_path, monkeypatch):
|
||||
"""Defense in depth: even if task_age ever raised, plugin_api must not 500.
|
||||
|
||||
The PR also added a try/except around the task_age call in
|
||||
`plugins/kanban/dashboard/plugin_api.py::_task_dict`. Verify a single
|
||||
corrupt row doesn't turn the whole board response into an error.
|
||||
"""
|
||||
# Set up an isolated kanban home so we can write a corrupt created_at.
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
kb.init_db()
|
||||
|
||||
# Insert a row with a non-int created_at (simulates the historical
|
||||
# bug that produced corrupt rows).
|
||||
conn = kb.connect()
|
||||
try:
|
||||
good_id = kb.create_task(conn, title="good")
|
||||
# Now write a row with corrupt created_at directly.
|
||||
conn.execute(
|
||||
"UPDATE tasks SET created_at = ? WHERE id = ?",
|
||||
("%s", good_id),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Re-read and pass through task_age — must not raise.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn, good_id)
|
||||
finally:
|
||||
conn.close()
|
||||
age = kb.task_age(task)
|
||||
assert age["created_age_seconds"] is None
|
||||
|
||||
@@ -75,10 +75,13 @@ def test_hallucinated_cards_fires_on_blocked_event():
|
||||
phantom_cards=["t_bad1", "t_bad2"],
|
||||
verified_cards=["t_good1"]),
|
||||
]
|
||||
diags = kd.compute_task_diagnostics(task, events, [])
|
||||
assert len(diags) == 1
|
||||
d = diags[0]
|
||||
assert d.kind == "hallucinated_cards"
|
||||
# ``now=300`` keeps the synthetic event timestamps in scope without
|
||||
# tripping the stranded_in_ready rule (events are 100/200 epoch
|
||||
# which time.time() would treat as ~50yr old).
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=300)
|
||||
halluc = [d for d in diags if d.kind == "hallucinated_cards"]
|
||||
assert len(halluc) == 1
|
||||
d = halluc[0]
|
||||
assert d.severity == "error"
|
||||
assert d.data["phantom_ids"] == ["t_bad1", "t_bad2"]
|
||||
# Generic recovery actions always available; comment action too.
|
||||
@@ -379,3 +382,176 @@ def test_broken_rule_is_isolated(monkeypatch):
|
||||
# The broken rule silently drops, the real one still fires.
|
||||
kinds = [d.kind for d in diags]
|
||||
assert "repeated_failures" in kinds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# stranded_in_ready
|
||||
#
|
||||
# Surfaces ready tasks that nobody has claimed within the threshold.
|
||||
# Identity-agnostic by design: catches typo'd assignees, deleted profiles,
|
||||
# down external worker pools, and misconfigured dispatchers in one rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stranded_in_ready_fires_when_age_exceeds_threshold():
|
||||
"""Default threshold = 30 min. A ready task promoted 45 min ago
|
||||
with no claim should fire as a warning."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo", claim_lock=None)
|
||||
# 45 min = 2700s, threshold = 1800s.
|
||||
events = [_event("created", ts=now - 45 * 60)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
assert stranded[0].severity == "warning"
|
||||
assert stranded[0].data["age_seconds"] == 45 * 60
|
||||
assert stranded[0].data["assignee"] == "demo"
|
||||
|
||||
|
||||
def test_stranded_in_ready_silent_below_threshold():
|
||||
"""A ready task only 10 min old should NOT fire."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo", claim_lock=None)
|
||||
events = [_event("created", ts=now - 10 * 60)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_skips_non_ready_status():
|
||||
"""Tasks not in ready status are out of scope (running tasks have
|
||||
their own crash / failure rules)."""
|
||||
now = 100_000
|
||||
for status in ("running", "blocked", "done", "todo", "triage"):
|
||||
task = _task(status=status, assignee="demo")
|
||||
events = [_event("created", ts=now - 6 * 3600)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == [], status
|
||||
|
||||
|
||||
def test_stranded_in_ready_skips_unassigned_tasks():
|
||||
"""Empty assignee = `skipped_unassigned` on the dispatcher already.
|
||||
Don't double-flag here."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="", claim_lock=None)
|
||||
events = [_event("created", ts=now - 6 * 3600)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_skips_claimed_tasks():
|
||||
"""A live claim_lock means a worker is on it — even an old one. Don't
|
||||
second-guess: the run-level liveness signal owns that decision."""
|
||||
now = 100_000
|
||||
task = _task(
|
||||
status="ready", assignee="demo", claim_lock="run_xyz",
|
||||
)
|
||||
events = [_event("created", ts=now - 6 * 3600)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_uses_latest_ready_transition():
|
||||
"""When multiple ready-transition events exist, the rule should
|
||||
age-from the most recent — a task reclaimed 20 min ago is NOT
|
||||
stranded for 6h even if it was first created 6h ago."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo")
|
||||
events = [
|
||||
_event("created", ts=now - 6 * 3600), # 6 h ago
|
||||
_event("reclaimed", ts=now - 20 * 60), # 20 min ago — wins
|
||||
]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
|
||||
|
||||
def test_stranded_in_ready_severity_escalates_with_age():
|
||||
"""warning → error → critical at 2x and 6x threshold."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo")
|
||||
# Default threshold = 1800s.
|
||||
cases = [
|
||||
(45 * 60, "warning"), # 1.5x → warning
|
||||
(90 * 60, "error"), # 3x → error
|
||||
(4 * 3600, "critical"), # 8x → critical
|
||||
]
|
||||
for age, expected in cases:
|
||||
events = [_event("created", ts=now - age)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1, f"age={age}"
|
||||
assert stranded[0].severity == expected, (
|
||||
f"age={age} expected {expected}, got {stranded[0].severity}"
|
||||
)
|
||||
|
||||
|
||||
def test_stranded_in_ready_respects_config_override():
|
||||
"""Config override changes the threshold."""
|
||||
now = 100_000
|
||||
task = _task(status="ready", assignee="demo")
|
||||
events = [_event("created", ts=now - 10 * 60)] # 10 min
|
||||
# Default 30 min — wouldn't fire.
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
assert [d for d in diags if d.kind == "stranded_in_ready"] == []
|
||||
# Lower the threshold to 5 min — now it fires.
|
||||
diags = kd.compute_task_diagnostics(
|
||||
task, events, [], now=now,
|
||||
config={"stranded_threshold_seconds": 5 * 60},
|
||||
)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
|
||||
|
||||
def test_stranded_in_ready_falls_back_to_created_at():
|
||||
"""When events have no ready-transition kind, the rule falls back
|
||||
to the task's ``created_at`` so an ancient stranded task isn't
|
||||
invisible just because its events got pruned."""
|
||||
now = 100_000
|
||||
task = _task(
|
||||
status="ready", assignee="demo", created_at=now - 4 * 3600,
|
||||
)
|
||||
# No qualifying events.
|
||||
events = [_event("commented", ts=now - 100)]
|
||||
diags = kd.compute_task_diagnostics(task, events, [], now=now)
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
assert stranded[0].data["age_seconds"] == 4 * 3600
|
||||
|
||||
|
||||
def test_stranded_in_ready_works_on_real_db_row(kanban_home):
|
||||
"""Round-trip through real kanban_db.connect() — confirms the rule
|
||||
works on sqlite3.Row objects, not just dicts."""
|
||||
import time as _t
|
||||
conn = kb.connect()
|
||||
try:
|
||||
# Create a task and force its created_at into the past.
|
||||
tid = kb.create_task(conn, title="stranded one", assignee="ghost")
|
||||
old_ts = int(_t.time()) - 90 * 60 # 90 min old
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', created_at = ? WHERE id = ?",
|
||||
(old_ts, tid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
task_row = conn.execute(
|
||||
"SELECT * FROM tasks WHERE id = ?", (tid,)
|
||||
).fetchone()
|
||||
events = list(conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at",
|
||||
(tid,),
|
||||
).fetchall())
|
||||
# Override created event timestamps too so age calc lines up.
|
||||
conn.execute(
|
||||
"UPDATE task_events SET created_at = ? WHERE task_id = ?",
|
||||
(old_ts, tid),
|
||||
)
|
||||
conn.commit()
|
||||
events = list(conn.execute(
|
||||
"SELECT * FROM task_events WHERE task_id = ?", (tid,),
|
||||
).fetchall())
|
||||
|
||||
diags = kd.compute_task_diagnostics(task_row, events, [])
|
||||
stranded = [d for d in diags if d.kind == "stranded_in_ready"]
|
||||
assert len(stranded) == 1
|
||||
assert stranded[0].data["assignee"] == "ghost"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
import pytest
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from hermes_cli import kanban_db as kb
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -76,7 +77,13 @@ async def test_notifier_unsubs_after_completed_event(kanban_home):
|
||||
@pytest.mark.parametrize('kind', ["gave_up", "crashed", "timed_out"])
|
||||
async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home):
|
||||
"""
|
||||
Event kind of gave_up, crashed, time_out would be cover, and remove subscription
|
||||
Event kinds gave_up / crashed / timed_out send a notification but DO
|
||||
NOT delete the subscription. The dispatcher may respawn the task and
|
||||
fire the same event kind again (e.g. a worker that crashes, gets
|
||||
reclaimed, and crashes a second time); the user must hear about the
|
||||
second event too. Subscriptions are removed only when the task hits
|
||||
a truly final status (done / archived) — see the comment on
|
||||
TERMINAL_KINDS in gateway/run.py and PR #21398.
|
||||
"""
|
||||
import hermes_cli.kanban_db as kb
|
||||
from gateway.run import GatewayRunner
|
||||
@@ -114,15 +121,27 @@ async def test_notifier_unsubs_after_abnormal_events(kind, kanban_home):
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
# The user is notified about the abnormal event...
|
||||
fake_adapter.send.assert_called_once()
|
||||
assert kind.replace('_', ' ') in fake_adapter.send.call_args[0][1]
|
||||
|
||||
# ...but the subscription survives so a respawn-then-same-event cycle
|
||||
# reaches the user too. The cursor (last_event_id) advanced inside
|
||||
# the same write txn as the claim, so the same event won't re-fire.
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert subs == [], "Subscription should be unsub after abnormal crash"
|
||||
assert len(subs) == 1, (
|
||||
f"Subscription should survive {kind!r} so the next cycle of the "
|
||||
f"same event reaches the user; got {subs!r}"
|
||||
)
|
||||
assert int(subs[0]["last_event_id"]) >= 1, (
|
||||
"Cursor should have advanced past the delivered event "
|
||||
"(claim_unseen_events_for_sub advances atomically inside the "
|
||||
"same write txn as the read)."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -301,3 +320,162 @@ def test_dispatcher_tick_does_not_call_init_db(kanban_home, monkeypatch):
|
||||
"_kanban_notifier_watcher must not call _kb.init_db(board=slug) — "
|
||||
"see issue #21378."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifier_skips_subscription_owned_by_other_profile(kanban_home):
|
||||
"""Each gateway keeps its watcher on, but only the subscribing profile claims."""
|
||||
import hermes_cli.kanban_db as kb
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.config import Platform
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="owned task", assignee="backend-engineer")
|
||||
kb.add_notify_sub(
|
||||
conn,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="chat1",
|
||||
notifier_profile="default",
|
||||
)
|
||||
kb.complete_task(conn, tid, result="done")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
runner._kanban_notifier_profile = "business-partner"
|
||||
|
||||
fake_adapter = MagicMock()
|
||||
fake_adapter.send = AsyncMock()
|
||||
runner.adapters = {Platform.TELEGRAM: fake_adapter}
|
||||
|
||||
_orig_sleep = asyncio.sleep
|
||||
tick_count = 0
|
||||
|
||||
async def _fast_sleep(_):
|
||||
nonlocal tick_count
|
||||
await _orig_sleep(0)
|
||||
tick_count += 1
|
||||
if tick_count >= 3:
|
||||
runner._running = False
|
||||
|
||||
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
|
||||
await asyncio.wait_for(
|
||||
runner._kanban_notifier_watcher(interval=1),
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
fake_adapter.send.assert_not_called()
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert len(subs) == 1
|
||||
assert int(subs[0]["last_event_id"]) == 0, "wrong profile must not claim the event"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notifier_delivers_subscription_owned_by_current_profile(kanban_home):
|
||||
"""The gateway for the profile that created/subscribed the task reports it."""
|
||||
import hermes_cli.kanban_db as kb
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.config import Platform
|
||||
|
||||
conn = kb.connect()
|
||||
try:
|
||||
tid = kb.create_task(conn, title="owned task", assignee="backend-engineer")
|
||||
kb.add_notify_sub(
|
||||
conn,
|
||||
task_id=tid,
|
||||
platform="telegram",
|
||||
chat_id="chat1",
|
||||
notifier_profile="default",
|
||||
)
|
||||
kb.complete_task(conn, tid, result="done")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._running = True
|
||||
runner._kanban_sub_fail_counts = {}
|
||||
runner._kanban_notifier_profile = "default"
|
||||
|
||||
fake_adapter = MagicMock()
|
||||
|
||||
async def _send_and_stop(chat_id, msg, metadata=None):
|
||||
runner._running = False
|
||||
|
||||
fake_adapter.send = AsyncMock(side_effect=_send_and_stop)
|
||||
runner.adapters = {Platform.TELEGRAM: fake_adapter}
|
||||
|
||||
_orig_sleep = asyncio.sleep
|
||||
|
||||
async def _fast_sleep(_):
|
||||
await _orig_sleep(0)
|
||||
|
||||
with patch("gateway.run.asyncio.sleep", side_effect=_fast_sleep):
|
||||
await asyncio.wait_for(
|
||||
runner._kanban_notifier_watcher(interval=1),
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
fake_adapter.send.assert_called_once()
|
||||
conn = kb.connect()
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn, tid)
|
||||
finally:
|
||||
conn.close()
|
||||
assert subs == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gateway_create_autosubscribes_on_explicit_board(kanban_home):
|
||||
"""`/kanban --board <slug> create ...` must subscribe on that board.
|
||||
|
||||
The gateway handler currently auto-subscribes after `/kanban create`,
|
||||
but the create detection must still work when the shared `--board`
|
||||
flag appears before the subcommand, and the subscription must land in
|
||||
that board's DB rather than the ambient/default board.
|
||||
"""
|
||||
from gateway.run import GatewayRunner
|
||||
from gateway.config import Platform
|
||||
|
||||
kb.create_board("projx")
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
source = SimpleNamespace(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="chat1",
|
||||
thread_id="th1",
|
||||
user_id="u1",
|
||||
)
|
||||
event = SimpleNamespace(
|
||||
text='/kanban --board projx create "hello" --assignee alice',
|
||||
source=source,
|
||||
)
|
||||
|
||||
out = await GatewayRunner._handle_kanban_command(runner, event)
|
||||
|
||||
assert "subscribed" in out.lower()
|
||||
|
||||
conn = kb.connect(board="projx")
|
||||
try:
|
||||
subs = kb.list_notify_subs(conn)
|
||||
tasks = kb.list_tasks(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert [t.title for t in tasks] == ["hello"]
|
||||
assert len(subs) == 1
|
||||
assert subs[0]["chat_id"] == "chat1"
|
||||
assert subs[0]["thread_id"] == "th1"
|
||||
|
||||
conn = kb.connect(board="default")
|
||||
try:
|
||||
assert kb.list_notify_subs(conn) == []
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -282,3 +283,48 @@ class TestIntegrationWithModelsModule:
|
||||
result = get_curated_nous_model_ids()
|
||||
|
||||
assert result == ["anthropic/claude-opus-4.7", "moonshotai/kimi-k2.6"]
|
||||
|
||||
def test_picker_nous_row_uses_manifest(self, tmp_path, monkeypatch):
|
||||
"""The /model picker must surface the manifest's nous list, not the
|
||||
in-repo _PROVIDER_MODELS["nous"] snapshot. Regression: before this
|
||||
fix, list_authenticated_providers() built the curated dict from
|
||||
_PROVIDER_MODELS only — so newly-added Portal models never reached
|
||||
the slash-command picker until the next Hermes release.
|
||||
"""
|
||||
# We deliberately do NOT use the ``isolated_home`` fixture here:
|
||||
# that fixture monkeypatches ``Path.home`` to ``tmp_path``, which
|
||||
# trips the auth-store seat-belt in ``_auth_file_path()`` because
|
||||
# ``HERMES_HOME / auth.json`` then resolves to the same path the
|
||||
# seat-belt thinks is the "real" user store. Use the autouse
|
||||
# ``_hermetic_environment`` HERMES_HOME directly instead.
|
||||
import importlib
|
||||
from hermes_cli import model_catalog
|
||||
importlib.reload(model_catalog)
|
||||
try:
|
||||
from hermes_cli.model_switch import list_picker_providers
|
||||
|
||||
active_home = Path(os.environ["HERMES_HOME"])
|
||||
(active_home / "auth.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"providers": {"nous": {"access_token": "fake"}},
|
||||
"credential_pool": {},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
model_catalog, "_fetch_manifest", return_value=_valid_manifest()
|
||||
):
|
||||
picker = list_picker_providers(
|
||||
current_provider="nous", max_models=99
|
||||
)
|
||||
finally:
|
||||
model_catalog.reset_cache()
|
||||
|
||||
nous_row = next((r for r in picker if r["slug"] == "nous"), None)
|
||||
assert nous_row is not None, "nous row must appear when authed"
|
||||
assert nous_row["models"] == [
|
||||
"anthropic/claude-opus-4.7",
|
||||
"moonshotai/kimi-k2.6",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Tests for session handoff (CLI to gateway platform).
|
||||
|
||||
The handoff state machine lives on the ``sessions`` table:
|
||||
|
||||
None → "pending" → "running" → ("completed" | "failed")
|
||||
|
||||
CLI side calls ``request_handoff`` and poll-waits on ``get_handoff_state``.
|
||||
Gateway side iterates ``list_pending_handoffs``, calls ``claim_handoff`` to
|
||||
flip pending → running, and finishes with ``complete_handoff`` or
|
||||
``fail_handoff``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
class TestHandoffStateDB:
|
||||
"""Test the handoff schema + helper methods on SessionDB."""
|
||||
|
||||
@pytest.fixture
|
||||
def db(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
return SessionDB(db_path=home / "state.db")
|
||||
|
||||
def _make_session(self, db, session_id, source="cli", title=None):
|
||||
"""Insert a session row directly for testing."""
|
||||
def _do(conn):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO sessions (id, source, title, started_at) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(session_id, source, title, time.time()),
|
||||
)
|
||||
db._execute_write(_do)
|
||||
|
||||
def test_columns_exist(self, db):
|
||||
db._conn.execute(
|
||||
"SELECT handoff_state, handoff_platform, handoff_error "
|
||||
"FROM sessions LIMIT 0"
|
||||
)
|
||||
|
||||
def test_request_handoff_marks_pending(self, db):
|
||||
sid = "sess-1"
|
||||
self._make_session(db, sid)
|
||||
|
||||
assert db.request_handoff(sid, "telegram") is True
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state == {
|
||||
"state": "pending",
|
||||
"platform": "telegram",
|
||||
"error": None,
|
||||
}
|
||||
|
||||
def test_request_handoff_rejects_in_flight(self, db):
|
||||
sid = "sess-2"
|
||||
self._make_session(db, sid)
|
||||
|
||||
assert db.request_handoff(sid, "telegram") is True
|
||||
# Still pending → reject re-request
|
||||
assert db.request_handoff(sid, "discord") is False
|
||||
|
||||
# And after gateway claims it (running) → still rejected
|
||||
assert db.claim_handoff(sid) is True
|
||||
assert db.request_handoff(sid, "discord") is False
|
||||
|
||||
def test_request_handoff_after_terminal_state_resets_error(self, db):
|
||||
sid = "sess-3"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.fail_handoff(sid, "earlier failure")
|
||||
|
||||
# User retries — should be allowed and clear the prior error.
|
||||
assert db.request_handoff(sid, "discord") is True
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state["state"] == "pending"
|
||||
assert state["platform"] == "discord"
|
||||
assert state["error"] is None
|
||||
|
||||
def test_list_pending_handoffs_excludes_running_and_terminal(self, db):
|
||||
a, b, c, d = "sess-a", "sess-b", "sess-c", "sess-d"
|
||||
for sid in (a, b, c, d):
|
||||
self._make_session(db, sid)
|
||||
|
||||
db.request_handoff(a, "telegram")
|
||||
db.request_handoff(b, "discord")
|
||||
db.request_handoff(c, "telegram")
|
||||
db.claim_handoff(c) # c is now running, not pending
|
||||
db.request_handoff(d, "slack")
|
||||
db.claim_handoff(d)
|
||||
db.complete_handoff(d) # d is terminal
|
||||
|
||||
pending = db.list_pending_handoffs()
|
||||
ids = [r["id"] for r in pending]
|
||||
assert set(ids) == {a, b}
|
||||
|
||||
def test_claim_handoff_is_atomic(self, db):
|
||||
sid = "sess-claim"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
|
||||
# First claim wins
|
||||
assert db.claim_handoff(sid) is True
|
||||
# Second claim is a no-op (state is now "running", not "pending")
|
||||
assert db.claim_handoff(sid) is False
|
||||
assert db.get_handoff_state(sid)["state"] == "running"
|
||||
|
||||
def test_complete_handoff_clears_error(self, db):
|
||||
sid = "sess-complete"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.fail_handoff(sid, "transient")
|
||||
# User retries; mock the watcher path
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.complete_handoff(sid)
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state["state"] == "completed"
|
||||
assert state["error"] is None
|
||||
|
||||
def test_fail_handoff_records_reason(self, db):
|
||||
sid = "sess-fail"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
db.fail_handoff(sid, "no home channel for telegram")
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert state["state"] == "failed"
|
||||
assert state["error"] == "no home channel for telegram"
|
||||
|
||||
def test_fail_handoff_truncates_long_reasons(self, db):
|
||||
sid = "sess-fail-long"
|
||||
self._make_session(db, sid)
|
||||
db.request_handoff(sid, "telegram")
|
||||
db.claim_handoff(sid)
|
||||
|
||||
# 1000-character error string
|
||||
big_err = "x" * 1000
|
||||
db.fail_handoff(sid, big_err)
|
||||
|
||||
state = db.get_handoff_state(sid)
|
||||
assert len(state["error"]) <= 500
|
||||
|
||||
def test_get_handoff_state_for_unknown_session(self, db):
|
||||
assert db.get_handoff_state("does-not-exist") is None
|
||||
|
||||
def test_full_pending_to_completed_flow(self, db):
|
||||
"""End-to-end sequence the CLI + gateway watcher follow."""
|
||||
sid = "sess-flow"
|
||||
self._make_session(db, sid, title="my session")
|
||||
db.append_message(sid, "user", "Hello")
|
||||
db.append_message(sid, "assistant", "Hi there!")
|
||||
|
||||
# CLI: request handoff
|
||||
assert db.request_handoff(sid, "telegram") is True
|
||||
assert db.get_handoff_state(sid)["state"] == "pending"
|
||||
|
||||
# Gateway watcher: discover + claim
|
||||
pending = db.list_pending_handoffs()
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["id"] == sid
|
||||
assert db.claim_handoff(sid) is True
|
||||
assert db.get_handoff_state(sid)["state"] == "running"
|
||||
|
||||
# Gateway uses get_messages to load the transcript (real flow uses
|
||||
# session_store.switch_session which reads the same table).
|
||||
messages = db.get_messages(sid)
|
||||
assert [m["role"] for m in messages] == ["user", "assistant"]
|
||||
|
||||
# Gateway: mark completed
|
||||
db.complete_handoff(sid)
|
||||
assert db.get_handoff_state(sid)["state"] == "completed"
|
||||
assert db.list_pending_handoffs() == []
|
||||
|
||||
|
||||
class TestHandoffCommandRegistration:
|
||||
"""Slash-command surface checks."""
|
||||
|
||||
def test_command_registered(self):
|
||||
from hermes_cli.commands import resolve_command
|
||||
cmd = resolve_command("handoff")
|
||||
assert cmd is not None
|
||||
assert cmd.name == "handoff"
|
||||
assert cmd.category == "Session"
|
||||
|
||||
def test_command_is_cli_only(self):
|
||||
"""`/handoff` is initiated from the CLI; gateway shouldn't expose it."""
|
||||
from hermes_cli.commands import resolve_command, GATEWAY_KNOWN_COMMANDS
|
||||
cmd = resolve_command("handoff")
|
||||
assert cmd is not None
|
||||
assert cmd.cli_only is True
|
||||
assert "handoff" not in GATEWAY_KNOWN_COMMANDS
|
||||
@@ -1946,6 +1946,117 @@ class TestNormaliseThemeExtensions:
|
||||
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
|
||||
|
||||
|
||||
class TestPluginAPIAuth:
|
||||
"""Tests that plugin API routes require the session token (issue #19533)."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
|
||||
"""Create a TestClient without the session token header."""
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
|
||||
self.client = TestClient(app)
|
||||
self.auth_client = TestClient(app)
|
||||
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
def test_plugin_route_requires_auth(self):
|
||||
"""Plugin API routes should return 401 without a valid session token."""
|
||||
# Use a known plugin route (kanban board)
|
||||
resp = self.client.get("/api/plugins/kanban/board")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_route_allows_auth(self):
|
||||
"""Plugin API routes should work with a valid session token.
|
||||
|
||||
Use ``/api/plugins/example/hello`` from the example-dashboard plugin —
|
||||
a stable, side-effect-free GET that's always loaded in tests. With a
|
||||
valid token the handler should run (200); without one the middleware
|
||||
should 401 before the handler is reached.
|
||||
"""
|
||||
# Without auth: middleware blocks before reaching the handler.
|
||||
resp = self.client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# With auth: handler runs.
|
||||
resp = self.auth_client.get("/api/plugins/example/hello")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_plugin_post_requires_auth(self):
|
||||
"""Plugin POST routes should return 401 without a valid session token."""
|
||||
resp = self.client.post("/api/plugins/kanban/tasks", json={"title": "test"})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_patch_requires_auth(self):
|
||||
"""Plugin PATCH routes should return 401 without a valid session token.
|
||||
|
||||
PATCH is the mutation method most commonly used by the dashboard for
|
||||
kanban task edits — explicitly cover it so a future middleware
|
||||
regression that whitelists non-GET methods can't sneak through.
|
||||
"""
|
||||
resp = self.client.patch(
|
||||
"/api/plugins/kanban/tasks/t_fake",
|
||||
json={"title": "renamed"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_delete_requires_auth(self):
|
||||
"""Plugin DELETE routes should return 401 without a valid session token."""
|
||||
resp = self.client.delete("/api/plugins/kanban/tasks/t_fake")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_non_kanban_plugin_route_requires_auth(self):
|
||||
"""Auth must be plugin-agnostic, not kanban-specific.
|
||||
|
||||
The middleware fix is at the gate level (no per-plugin allowlist),
|
||||
so any plugin's API surface — kanban, hermes-achievements, future
|
||||
plugins — must require the session token. Hit a non-kanban plugin
|
||||
path to lock that in.
|
||||
"""
|
||||
# Real plugin path (hermes-achievements is loaded by default).
|
||||
resp = self.client.get("/api/plugins/hermes-achievements/overview")
|
||||
assert resp.status_code == 401
|
||||
# Same for an arbitrary plugin namespace that doesn't even exist —
|
||||
# the middleware should 401 before routing decides 404, so an
|
||||
# attacker can't fingerprint plugin names by status codes.
|
||||
resp = self.client.get("/api/plugins/_definitely_not_a_plugin_/anything")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_plugin_websocket_unaffected_by_http_middleware(self):
|
||||
"""The kanban /events WebSocket has its own ``?token=`` check;
|
||||
the HTTP middleware change must not start gating WS upgrades.
|
||||
|
||||
Starlette doesn't run HTTP middleware on WebSocket upgrades anyway,
|
||||
but pin the behavior so a future refactor that moves auth into a
|
||||
shared layer can't silently break the WS auth contract.
|
||||
"""
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
from hermes_cli.web_server import _SESSION_TOKEN
|
||||
|
||||
# Without a token the WS endpoint must close the upgrade itself
|
||||
# (its own _check_ws_token), NOT 401 from the HTTP middleware.
|
||||
try:
|
||||
with self.client.websocket_connect(
|
||||
"/api/plugins/kanban/events"
|
||||
):
|
||||
pass # if we got here without disconnect, the WS accepted us
|
||||
except WebSocketDisconnect:
|
||||
pass # expected — WS endpoint rejected via its own check
|
||||
except Exception:
|
||||
# The kanban plugin may not be mounted in this test environment,
|
||||
# in which case the route doesn't exist at all (3xx/4xx during
|
||||
# upgrade). That's fine for this regression — it only matters
|
||||
# that the HTTP middleware didn't start intercepting WS upgrades.
|
||||
pass
|
||||
|
||||
|
||||
class TestDashboardPluginManifestExtensions:
|
||||
"""Tests for the extended plugin manifest fields (tab.override,
|
||||
tab.hidden, slots) read by _discover_dashboard_plugins()."""
|
||||
|
||||
@@ -13,7 +13,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.main import _web_ui_build_needed, _build_web_ui
|
||||
from hermes_cli.main import _web_ui_build_needed, _build_web_ui, _run_npm_install_deterministic
|
||||
|
||||
|
||||
def _touch(path: Path, offset: float = 0.0) -> None:
|
||||
@@ -119,3 +119,92 @@ class TestBuildWebUISkipsWhenFresh:
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 2 # npm install + npm run build
|
||||
|
||||
def test_npm_install_uses_utf8_replace_output_decoding(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.subprocess.run", return_value=mock_cp) as mock_run:
|
||||
result = _run_npm_install_deterministic("/usr/bin/npm", web_dir)
|
||||
|
||||
assert result.returncode == 0
|
||||
_, kwargs = mock_run.call_args
|
||||
assert kwargs["text"] is True
|
||||
assert kwargs["encoding"] == "utf-8"
|
||||
assert kwargs["errors"] == "replace"
|
||||
|
||||
def test_web_build_uses_utf8_replace_output_decoding(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
|
||||
mock_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main.subprocess.run", side_effect=[mock_cp, mock_cp]) as mock_run:
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
_, build_kwargs = mock_run.call_args_list[1]
|
||||
assert build_kwargs["text"] is True
|
||||
assert build_kwargs["encoding"] == "utf-8"
|
||||
assert build_kwargs["errors"] == "replace"
|
||||
|
||||
|
||||
class TestBuildWebUIRetryAndStaleFallback:
|
||||
"""Coverage for the retry + stale-dist fallback added in #23824 / issue #23817."""
|
||||
|
||||
def test_retries_build_once_on_failure(self, tmp_path):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
Subprocess = __import__("subprocess")
|
||||
# install: success; build attempt 1: fail; build attempt 2: success
|
||||
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="EPERM")
|
||||
build_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._time.sleep") as mock_sleep, \
|
||||
patch("hermes_cli.main.subprocess.run",
|
||||
side_effect=[install_ok, build_fail, build_ok]) as mock_run:
|
||||
result = _build_web_ui(web_dir)
|
||||
|
||||
assert result is True
|
||||
assert mock_run.call_count == 3 # install + build + retry
|
||||
mock_sleep.assert_called_once_with(3)
|
||||
|
||||
def test_falls_back_to_stale_dist_when_retry_also_fails(self, tmp_path, capsys):
|
||||
web_dir, dist_dir = _make_web_dir(tmp_path)
|
||||
# Stale dist exists but is older than source
|
||||
_touch(dist_dir / "index.html", offset=-100)
|
||||
_touch(web_dir / "src" / "App.tsx") # newer source -> build_needed=True
|
||||
|
||||
Subprocess = __import__("subprocess")
|
||||
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._time.sleep"), \
|
||||
patch("hermes_cli.main.subprocess.run",
|
||||
side_effect=[install_ok, build_fail, build_fail]):
|
||||
result = _build_web_ui(web_dir, fatal=True)
|
||||
|
||||
# MUST return True (serve stale) — issue #23817 — even with fatal=True,
|
||||
# because cmd_dashboard passes fatal=True and is the primary caller.
|
||||
assert result is True
|
||||
out = capsys.readouterr().out
|
||||
assert "serving stale dist as fallback" in out
|
||||
assert "vite ENOMEM" in out # stderr surfaced to user
|
||||
|
||||
def test_hard_fails_when_no_dist_to_fall_back_to(self, tmp_path, capsys):
|
||||
web_dir, _ = _make_web_dir(tmp_path)
|
||||
|
||||
Subprocess = __import__("subprocess")
|
||||
install_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
build_fail = Subprocess.CompletedProcess([], 1, stdout="", stderr="vite ENOMEM")
|
||||
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
||||
patch("hermes_cli.main._time.sleep"), \
|
||||
patch("hermes_cli.main.subprocess.run",
|
||||
side_effect=[install_ok, build_fail, build_fail]):
|
||||
result = _build_web_ui(web_dir, fatal=True)
|
||||
|
||||
assert result is False
|
||||
out = capsys.readouterr().out
|
||||
assert "Web UI build failed" in out
|
||||
assert "vite ENOMEM" in out
|
||||
assert "Run manually" in out
|
||||
|
||||
Reference in New Issue
Block a user