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.
135 lines
4.9 KiB
Python
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) == []
|