mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
370 lines
16 KiB
Python
370 lines
16 KiB
Python
"""Tests for _web_ui_build_needed — staleness check for the web UI dist.
|
|
|
|
The freshness check uses a SHA-256 content hash of the web source tree
|
|
(mirroring the desktop build), recorded in a stamp file under $HERMES_HOME,
|
|
NOT mtime comparison — so ``git pull`` / ``hermes update`` that rewrite
|
|
source mtimes without changing content no longer fool it.
|
|
|
|
Critical invariant: the dashboard Vite build outputs to hermes_cli/web_dist/
|
|
(vite.config.ts: outDir: "../../hermes_cli/web_dist"), NOT web/dist/.
|
|
The sentinel must be checked in the correct output directory or the
|
|
freshness check is a no-op and the OOM rebuild always runs.
|
|
"""
|
|
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.main import (
|
|
_web_ui_build_needed,
|
|
_build_web_ui,
|
|
_compute_web_ui_content_hash,
|
|
_missing_web_build_tool,
|
|
_run_npm_install_deterministic,
|
|
_web_build_toolchain_ready,
|
|
_web_toolchain_roots,
|
|
_web_ui_stamp_path,
|
|
_write_web_ui_build_stamp,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolated_hermes_home(tmp_path, monkeypatch):
|
|
"""Keep web-build-stamp writes inside the test's tmp dir, never the real home."""
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "_hermes_home"))
|
|
|
|
|
|
def _touch(path: Path, offset: float = 0.0) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.touch()
|
|
if offset:
|
|
t = time.time() + offset
|
|
os.utime(path, (t, t))
|
|
|
|
|
|
def _make_web_dir(tmp_path: Path) -> tuple[Path, Path]:
|
|
"""Return (web_dir, dist_dir) matching real repo layout."""
|
|
web_dir = tmp_path / "web"
|
|
web_dir.mkdir(parents=True)
|
|
(web_dir / "package.json").touch()
|
|
dist_dir = tmp_path / "hermes_cli" / "web_dist"
|
|
return web_dir, dist_dir
|
|
|
|
|
|
class TestWebUIBuildNeeded:
|
|
"""Content-hash staleness — replaces the old mtime comparison.
|
|
|
|
The dashboard build hashes the web source tree (like the desktop build)
|
|
instead of comparing mtimes, so git operations that rewrite mtimes
|
|
without changing content no longer fool the freshness check.
|
|
"""
|
|
|
|
@staticmethod
|
|
def _root(web_dir: Path) -> Path:
|
|
return web_dir.parent.parent if web_dir.parent.name == "apps" else web_dir.parent
|
|
|
|
def _stamp_current(self, web_dir: Path) -> None:
|
|
"""Record a stamp matching web_dir's current source content."""
|
|
_write_web_ui_build_stamp(self._root(web_dir), web_dir)
|
|
|
|
|
|
|
|
|
|
|
|
def test_mtime_only_change_is_not_stale(self, tmp_path):
|
|
"""The whole point: bumping mtimes without changing bytes (what
|
|
``git pull`` / ``hermes update`` do) must NOT report stale."""
|
|
web_dir, dist_dir = _make_web_dir(tmp_path)
|
|
src = web_dir / "src" / "App.tsx"
|
|
src.parent.mkdir(parents=True, exist_ok=True)
|
|
src.write_text("export const A = 1\n")
|
|
(dist_dir / ".vite").mkdir(parents=True, exist_ok=True)
|
|
(dist_dir / ".vite" / "manifest.json").write_text("{}")
|
|
self._stamp_current(web_dir)
|
|
assert _web_ui_build_needed(web_dir) is False
|
|
future = time.time() + 10_000
|
|
os.utime(src, (future, future))
|
|
os.utime(web_dir / "package.json", (future, future))
|
|
assert _web_ui_build_needed(web_dir) is False
|
|
|
|
|
|
|
|
def test_content_hash_is_deterministic(self, tmp_path):
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
(web_dir / "src").mkdir(parents=True, exist_ok=True)
|
|
(web_dir / "src" / "App.tsx").write_text("export const A = 1\n")
|
|
root = self._root(web_dir)
|
|
h1 = _compute_web_ui_content_hash(root, web_dir)
|
|
h2 = _compute_web_ui_content_hash(root, web_dir)
|
|
assert h1 == h2
|
|
assert len(h1) == 64
|
|
|
|
def test_write_stamp_creates_file_with_hash(self, tmp_path):
|
|
import json as _json
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
(web_dir / "src").mkdir(parents=True, exist_ok=True)
|
|
(web_dir / "src" / "App.tsx").write_text("export const A = 1\n")
|
|
self._stamp_current(web_dir)
|
|
stamp = _web_ui_stamp_path()
|
|
assert stamp.is_file()
|
|
data = _json.loads(stamp.read_text())
|
|
assert data["contentHash"] == _compute_web_ui_content_hash(self._root(web_dir), web_dir)
|
|
|
|
|
|
|
|
class TestBuildWebUISkipsWhenFresh:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_web_install_omits_workspace_when_web_has_own_lockfile(
|
|
self, tmp_path, monkeypatch
|
|
):
|
|
"""web/ with its own lockfile => _workspace_root returns web_dir, so
|
|
--workspace web would fail (npm can't find that workspace from inside
|
|
web/). The flag must be dropped and the install run plainly from web_dir.
|
|
Symmetric to the TUI fix in test_tui_npm_install.py. See #42973.
|
|
|
|
With web's own lockfile present at cwd, _run_npm_install_deterministic
|
|
uses ``npm ci`` (not ``npm install``).
|
|
"""
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
(web_dir / "package-lock.json").write_text("{}", encoding="utf-8")
|
|
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
|
monkeypatch.delenv("TERMUX_VERSION", raising=False)
|
|
monkeypatch.setenv("PREFIX", "/usr")
|
|
|
|
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
|
build_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", return_value=install_cp) as mock_run, \
|
|
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp):
|
|
result = _build_web_ui(web_dir)
|
|
|
|
assert result is True
|
|
args, kwargs = mock_run.call_args
|
|
assert "--workspace" not in args[0]
|
|
assert args[0] == ["/usr/bin/npm", "ci", "--include=dev", "--silent"]
|
|
assert kwargs["cwd"] == web_dir
|
|
|
|
def test_web_build_uses_idle_timeout_helper(self, tmp_path):
|
|
"""npm run build now goes through _run_with_idle_timeout (issue #33788).
|
|
|
|
The install step keeps its capture_output behavior (the existing
|
|
retry-on-EPERM contract depends on it); only the long-running build
|
|
step is streamed + idle-killed.
|
|
"""
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
|
|
install_cp = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
|
build_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", return_value=install_cp), \
|
|
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_cp) as mock_idle:
|
|
result = _build_web_ui(web_dir)
|
|
|
|
assert result is True
|
|
# Build was invoked through the idle-timeout helper, not subprocess.run.
|
|
mock_idle.assert_called_once()
|
|
args, kwargs = mock_idle.call_args
|
|
# Positional: [npm, "run", "build"]; cwd passed as kwarg.
|
|
assert args[0] == ["/usr/bin/npm", "run", "build"]
|
|
assert kwargs["cwd"] == web_dir
|
|
|
|
|
|
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_ok = Subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
|
# build attempt 1: fail; build attempt 2: success.
|
|
build_fail = Subprocess.CompletedProcess([], 1, stdout="EPERM", stderr="")
|
|
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", return_value=install_ok), \
|
|
patch("hermes_cli.main._run_with_idle_timeout",
|
|
side_effect=[build_fail, build_ok]) as mock_idle:
|
|
result = _build_web_ui(web_dir)
|
|
|
|
assert result is True
|
|
assert mock_idle.call_count == 2 # 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="vite ENOMEM", stderr="")
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._time.sleep"), \
|
|
patch("hermes_cli.main.subprocess.run", return_value=install_ok), \
|
|
patch("hermes_cli.main._run_with_idle_timeout",
|
|
side_effect=[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 # combined output surfaced to user
|
|
|
|
|
|
class TestBuildWebUIFlock:
|
|
"""Cross-process build serialization (salvaged from PR #63455).
|
|
|
|
One process builds under an exclusive flock on <root>/.web_ui_build.lock;
|
|
contenders either serve the existing (possibly stale) dist or, when no
|
|
dist exists yet, block until the builder finishes. The staleness walk
|
|
itself runs inside _do_build_web_ui, i.e. under the lock, so a process
|
|
that queued behind a successful build skips the rebuild.
|
|
"""
|
|
|
|
|
|
|
|
def test_contended_lock_without_dist_waits_then_skips_fresh_build(self, tmp_path):
|
|
"""First-ever build race: the waiter blocks, and once it acquires the
|
|
lock the callee's own staleness check (running under the lock) sees
|
|
the winner's output and skips a duplicate build."""
|
|
import fcntl
|
|
import threading
|
|
from hermes_cli.main import _build_web_ui as build
|
|
|
|
web_dir, dist_dir = _make_web_dir(tmp_path)
|
|
# No dist yet — contender must take the blocking-wait path.
|
|
lock_path = tmp_path / ".web_ui_build.lock"
|
|
holder = open(lock_path, "a")
|
|
fcntl.flock(holder.fileno(), fcntl.LOCK_EX)
|
|
|
|
def release_after_building():
|
|
# Simulate the winning process finishing its build.
|
|
_touch(dist_dir / ".vite" / "manifest.json")
|
|
_write_web_ui_build_stamp(tmp_path, web_dir)
|
|
holder.close() # releases the flock
|
|
|
|
t = threading.Timer(0.2, release_after_building)
|
|
t.start()
|
|
try:
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main.subprocess.run") as mock_run:
|
|
result = build(web_dir)
|
|
finally:
|
|
t.join()
|
|
|
|
assert result is True
|
|
mock_run.assert_not_called() # fresh after the wait -> no rebuild
|
|
|
|
def test_lock_file_is_gitignored(self):
|
|
gitignore = Path(__file__).resolve().parents[2] / ".gitignore"
|
|
assert ".web_ui_build.lock" in gitignore.read_text(encoding="utf-8")
|
|
|
|
|
|
def _link_shims(bin_dir: Path, *names: str) -> None:
|
|
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
for name in names:
|
|
(bin_dir / name).touch()
|
|
|
|
|
|
class TestWebBuildToolchainReady:
|
|
"""A tree is ready when the build can resolve tsc AND vite from any root.
|
|
|
|
``npm run build`` searches ``node_modules/.bin`` from the script's own
|
|
package up through every ancestor, so a shim in either place counts.
|
|
"""
|
|
|
|
def test_missing_toolchain_is_not_ready(self, tmp_path):
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
assert _web_build_toolchain_ready(web_dir, tmp_path) is False
|
|
|
|
|
|
def test_hoisted_shims_at_workspace_root_are_ready(self, tmp_path):
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
_link_shims(tmp_path / "node_modules" / ".bin", "tsc", "vite")
|
|
assert _web_build_toolchain_ready(web_dir, tmp_path) is True
|
|
|
|
|
|
@pytest.mark.parametrize("shim", ["tsc.cmd", "tsc.ps1", "tsc.exe"])
|
|
def test_windows_shim_extensions_count(self, tmp_path, shim):
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
_link_shims(tmp_path / "node_modules" / ".bin", shim, "vite.cmd")
|
|
assert _web_build_toolchain_ready(web_dir, tmp_path) is True
|
|
|
|
|
|
class TestWebToolchainRoots:
|
|
def test_searches_the_package_and_its_workspace_root(self, tmp_path):
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
assert _web_toolchain_roots(web_dir) == (web_dir, tmp_path)
|
|
|
|
|
|
class TestMissingWebBuildTool:
|
|
"""Every shell words an unresolvable binary differently."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"output,expected",
|
|
[
|
|
("sh: 1: tsc: not found\nnpm error code 127", "tsc"),
|
|
("bash: line 1: vite: command not found", "vite"),
|
|
("'tsc' is not recognized as an internal or external command", "tsc"),
|
|
("error TS2307: Cannot find module './x'", None),
|
|
("", None),
|
|
],
|
|
)
|
|
def test_detects_the_unresolvable_tool(self, output, expected):
|
|
assert _missing_web_build_tool(output) == expected
|
|
|
|
|
|
class TestBuildRecoversFromMissingToolchain:
|
|
def test_reinstalls_and_retries_when_the_build_cannot_resolve_tsc(self, tmp_path):
|
|
"""The generic retry reruns the same command, so it can't fix this alone."""
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
|
install_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
|
build_fail = __import__("subprocess").CompletedProcess(
|
|
[], 127, stdout="sh: 1: tsc: not found\n", stderr=""
|
|
)
|
|
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
|
|
|
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
|
patch("hermes_cli.main._run_with_idle_timeout", side_effect=[build_fail, build_ok]) as mock_build, \
|
|
patch("hermes_cli.main._web_ui_build_needed", return_value=True), \
|
|
patch("hermes_cli.main._write_web_ui_build_stamp"), \
|
|
patch("hermes_cli.main._time.sleep"):
|
|
result = _build_web_ui(web_dir)
|
|
|
|
assert result is True
|
|
assert mock_install.call_count == 2
|
|
assert mock_build.call_count == 2
|
|
|
|
def test_healthy_tree_builds_without_an_extra_install(self, tmp_path):
|
|
"""No pre-build probing: a build that works is never second-guessed."""
|
|
web_dir, _ = _make_web_dir(tmp_path)
|
|
(tmp_path / "package-lock.json").write_text("{}", encoding="utf-8")
|
|
_link_shims(web_dir / "node_modules" / ".bin", "tsc", "vite")
|
|
install_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
|
build_ok = __import__("subprocess").CompletedProcess([], 0, stdout="", stderr="")
|
|
|
|
with patch("hermes_cli.main._resolve_node_runtime_npm", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
|
|
patch("hermes_cli.main._run_with_idle_timeout", return_value=build_ok) as mock_build, \
|
|
patch("hermes_cli.main._web_ui_build_needed", return_value=True), \
|
|
patch("hermes_cli.main._write_web_ui_build_stamp"):
|
|
result = _build_web_ui(web_dir)
|
|
|
|
assert result is True
|
|
assert mock_install.call_count == 1
|
|
assert mock_build.call_count == 1
|
|
|