Files
hermes-agent/tests/hermes_cli/test_web_ui_build.py
teknium1 b63c138d78 fix: Hermes never falls back to the user's node/npm/npx/uv
Maintainer ruling: only Hermes and only its packaged package managers
(uv/pip/node/npm) are ever used; no PATH fallback when the managed tool is
missing, no "prefer the user's if new enough".

- hermes_constants.find_node_executable: node/npm/npx resolve to PM's
  installed copy or None. Every caller already pm.ensure()s on None, so a
  missing runtime is now provisioned instead of silently borrowing the
  user's Node (native-addon ABI / npm cache mismatches).
- agent/lsp/install._install_npm: pm.ensure('npm') when PM npm is absent,
  instead of failing over to whatever npm is on PATH.
- gateway._append_node_dir_for_service: stop baking the invoker's PATH node
  dir into generated systemd/launchd units.
- main_install_repair._resolve_node_runtime_npm: drop the PATH re-scan for
  another npm.
- source_build.source_product_current: run the freshness reader only with
  PM's node.
- doctor: Node/npm rows and npm audit use PM's copies (Termux APT distro
  keeps its system Node).
- install.sh ensure_uv / install.ps1 Get-Uv: always stage the pinned uv
  artifact; delete the "uv on PATH if new enough" developer shortcut.
2026-09-27 22:04:26 -07:00

135 lines
4.9 KiB
Python

"""Web launch keeps freshness/serialization but never masks a failed build."""
import os
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli.main_web_build import _build_web_ui, _web_ui_build_needed
from tests.hermes_cli.test_source_build import stamp_product, copy_freshness_scripts, use_host_node_as_pm_node
from tests.hermes_cli.test_source_build import source_checkout, source_products, _events # noqa: F401
@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."""
copy_freshness_scripts(tmp_path)
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
@pytest.mark.platforms("posix")
def test_web_build_prepares_once_and_skips_a_current_product(source_products):
root, acquired = source_products
assert _build_web_ui(root / "web", fatal=True)
assert [event["step"] for event in _events(root)] == ["deps", "web"]
assert acquired == ["npm"]
assert not _web_ui_build_needed(root / "web")
assert _build_web_ui(root / "web", fatal=True)
assert acquired == ["npm"]
assert len(_events(root)) == 2
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("fatal", [False, True])
def test_web_failure_is_not_success_even_with_an_old_dist(source_products, fatal):
root, acquired = source_products
dist = root / "hermes_cli/web_dist/index.html"
dist.parent.mkdir(parents=True)
dist.write_text("old product")
(root / "fail-web").touch()
assert not _build_web_ui(root / "web", fatal=fatal)
assert acquired == ["npm"]
assert [event["step"] for event in _events(root)] == ["deps", "web"]
assert dist.read_text() == "old product"
assert not (root / "hermes_cli/web_dist/hermes-build.json").exists()
@pytest.mark.platforms("posix")
def test_failed_preparation_never_runs_web_compilation(source_products):
root, acquired = source_products
(root / "package-lock.json").write_text("not json")
assert not _build_web_ui(root / "web", fatal=True)
assert acquired == ["npm"]
assert _events(root) == []
assert not (root / "hermes_cli/web_dist/hermes-build.json").exists()
@pytest.mark.platforms("linux")
def test_web_rebuild_reuses_the_existing_desktop_union(source_products):
from hermes_cli.source_build import build_update_products
root, acquired = source_products
build_update_products(root, desktop=True)
before = _events(root)
(root / "web/changed.ts").write_text("changed web source")
assert _build_web_ui(root / "web", fatal=True)
assert _events(root) == [*before, {"step": "web"}]
assert acquired == ["npm", "npm"]
assert (root / "node_modules/apps-desktop").exists()
@pytest.mark.platforms("linux")
@pytest.mark.parametrize('existing', [False, True])
def test_contended_build_waits_and_rechecks_winner(tmp_path, monkeypatch, existing):
import fcntl
import threading
use_host_node_as_pm_node(monkeypatch)
web, dist = _make_web_dir(tmp_path)
if existing:
_touch(dist / 'index.html')
holder = open(tmp_path / '.web_ui_build.lock', 'a', encoding='utf-8')
fcntl.flock(holder, fcntl.LOCK_EX)
real_flock = fcntl.flock
contended = threading.Event()
def flock(fd, operation):
contended.set()
return real_flock(fd, operation)
monkeypatch.setattr(fcntl, 'flock', flock)
def finish():
try:
assert contended.wait(10), 'waiter never attempted the lock'
_touch(dist / 'index.html')
(dist / 'index.html').write_text('winner', encoding='utf-8')
stamp_product(tmp_path, 'web', dist)
finally:
holder.close()
worker = threading.Thread(target=finish)
worker.start()
try:
with patch('hermes_cli.source_build.source_build_env', side_effect=AssertionError('duplicate preparation')):
assert _build_web_ui(web, fatal=True)
assert (dist / 'index.html').read_text(encoding='utf-8') == 'winner'
finally:
worker.join(timeout=15)
holder.close()
assert not worker.is_alive()
@pytest.mark.platforms("posix")
def test_lock_open_failure_does_not_start_an_unprotected_build(source_products):
root, acquired = source_products
(root / ".web_ui_build.lock").mkdir()
assert not _build_web_ui(root / "web", fatal=True)
assert acquired == []
assert _events(root) == []