Files
hermes-agent/tests/hermes_cli/test_source_build.py
ethernet 5e4a2a3d24 refactor(pm): remove legacy dependency and launch managers
Competing installers and checkout-local venv assumptions bypassed PM
selection, install consent, and generation lifetimes. Route consumers
through PM and installation-bound launchers. Refresh source launchers
before obsolete Python entries can be collected.

Remove Node, browser, and CUA acquisition engines, obsolete venv-holder
handling, detached sync, and unused PM APIs. Keep historical updater
exports inert and preserve external tool ownership and native integration.

Share product freshness and prepared inputs across builders. Align plugin
admission, Docker provisioning, setup instructions, and behavioral tests.

Verified targeted Python and JavaScript tests, desktop and web typechecks,
scoped lint, real product builds, and the Docker frontend smoke test.
The missed post-setup test cleanup is included and verified.

Native Windows/macOS execution, full Rust compilation, and the complete
repository suite remain unverified. Historical compatibility requirements
were preserved and extended, not fully rescanned.
2026-09-12 14:57:38 -04:00

278 lines
12 KiB
Python

"""Source orchestration uses real node-deps/npm in an isolated checkout.
Only PM's tool acquisition is substituted with the host's node/npm. Small
workspace scripts stand in for the expensive UI compilers; subprocess failures,
locked dependency selection, environment propagation and publication are real.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
import pm
from pm.package import Runner
def copy_freshness_scripts(root):
repository = Path(__file__).resolve().parents[2]
scripts = root / "scripts/build"
scripts.mkdir(parents=True, exist_ok=True)
for name in ("freshness.mjs", "frontend-common.mjs"):
shutil.copy2(repository / "scripts/build" / name, scripts / name)
def stamp_product(root, product, out):
script = (root / "scripts/build/freshness.mjs").as_uri()
subprocess.run([shutil.which("node"), "--input-type=module", "-e",
f"import {{recordProduct, buildInputs}} from {json.dumps(script)};"
"const [source, product, out] = process.argv.slice(1);"
"recordProduct({source, product, out, inputs: buildInputs(source, product)});",
str(root), product, str(out)], check=True)
def test_automatic_build_preserves_pm_admission_intent(monkeypatch):
from hermes_cli.source_build import source_build_env
intent = []
def acquire(name, *, base_env, explicit):
intent.append(explicit)
return Runner(name, base_env)
monkeypatch.setattr(pm, "ensure", acquire)
source_build_env()
assert intent == [False]
def test_installed_npm_does_not_authorize_missing_workspace_dependencies(source_checkout, monkeypatch):
from hermes_cli.source_build import prepare_source_dependencies, source_build_env
root, _ = source_checkout
env = source_build_env()
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "1")
with pytest.raises(subprocess.CalledProcessError):
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert _events(root) == []
prepare_source_dependencies(root, ("ui-tui", "web"), env=env, explicit=True)
events = _events(root)
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert _events(root) == events
@pytest.fixture
def source_checkout(tmp_path, monkeypatch):
node, npm = shutil.which("node"), shutil.which("npm")
assert node and npm, "source-build integration requires node and npm"
home = tmp_path / "home"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_RUNTIME_DIR", str(tmp_path / "tools"))
monkeypatch.setenv("HERMES_DISABLE_LAZY_INSTALLS", "0")
monkeypatch.setenv("npm_config_cache", str(tmp_path / "npm-cache"))
monkeypatch.setenv("ESBUILD_BINARY_PATH", "/wrong/esbuild")
monkeypatch.setenv("HERMES_PYTHON", "/wrong/python")
(home / "npmrc").write_text("fund=false\n", encoding="utf-8")
monkeypatch.delenv("NPM_CONFIG_USERCONFIG", raising=False)
acquired = []
def acquire(name, *, base_env=None, explicit=False):
acquired.append(name)
assert name == "npm"
return Runner(name, {**(base_env or os.environ), "PATH": os.pathsep.join(
[str(Path(npm).parent), str(Path(node).parent), os.environ["PATH"]])})
monkeypatch.setattr(pm, "ensure", acquire)
root = tmp_path / "source with spaces"
root.mkdir()
workspaces = ["ui-tui", "web", "apps/desktop", "unrelated"]
manifest = {"name": "build-fixture", "private": True, "version": "1.0.0",
"workspaces": workspaces, "scripts": {"postinstall": "node log.mjs deps"}}
(root / "package.json").write_text(json.dumps(manifest), encoding="utf-8")
for workspace in workspaces:
directory = root / workspace
directory.mkdir(parents=True)
(directory / "package.json").write_text(json.dumps({
"name": workspace.replace("/", "-"), "version": "1.0.0",
"scripts": {"build": "node ../../scripts/build/build-desktop.mjs",
"builder": "node ../../scripts/build/package-desktop.mjs"}
if workspace == "apps/desktop" else {},
}), encoding="utf-8")
(root / "log.mjs").write_text(
"import { appendFileSync } from 'node:fs';\n"
"appendFileSync('events.jsonl', JSON.stringify({step: process.argv[2], "
"python: process.env.HERMES_PYTHON, ci: process.env.CI, "
"esbuild: process.env.ESBUILD_BINARY_PATH, "
"npmrc: process.env.NPM_CONFIG_USERCONFIG}) + '\\n');\n",
encoding="utf-8",
)
subprocess.run([npm, "install", "--package-lock-only", "--ignore-scripts", "--offline",
"--no-audit", "--no-fund"], cwd=root, check=True)
scripts = root / "scripts" / "build"
scripts.mkdir(parents=True)
repository = Path(__file__).resolve().parents[2]
for name in ("node-deps.mjs", "freshness.mjs", "frontend-common.mjs"):
shutil.copy2(repository / "scripts/build" / name, scripts / name)
(root / ".gitignore").write_text("node_modules/\n**/dist/\n", encoding="utf-8")
return root, acquired
@pytest.fixture
def source_products(source_checkout):
root, acquired = source_checkout
(root / "product.mjs").write_text(
"import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';\n"
"import { dirname, join } from 'node:path';\n"
"import { fileURLToPath } from 'node:url';\n"
"import { recordProduct, buildInputs } from './scripts/build/freshness.mjs';\n"
"const root = dirname(fileURLToPath(import.meta.url));\n"
"export function build(step, output) {\n"
" appendFileSync(join(root, 'events.jsonl'), JSON.stringify({step}) + '\\n');\n"
" if (existsSync(join(root, 'fail-' + step))) throw new Error('fixture ' + step + ' failure');\n"
" if (step === 'web' && !existsSync(join(root, 'web/public/favicon.ico'))) throw new Error('icons missing');\n"
" const path = join(root, output); mkdirSync(dirname(path), { recursive: true });\n"
" writeFileSync(path, step);\n"
" if (step !== 'icons') {\n"
" let out = dirname(path);\n"
" if (step === 'desktop') { out = join(out, 'resources/app.asar.unpacked/dist');\n"
" mkdirSync(out, {recursive: true}); writeFileSync(join(out, 'index.html'), 'renderer'); }\n"
" recordProduct({source: root, product: step, out, inputs: buildInputs(root, step)});\n"
" }\n"
"}\n",
encoding="utf-8",
)
for script, step, output in [
("generate-icons.mjs", "icons", "web/public/favicon.ico"),
("build/tui.mjs", "tui", "ui-tui/dist/entry.js"),
("build/web.mjs", "web", "hermes_cli/web_dist/index.html"),
]:
relative = "../../" if script.startswith("build/") else "../"
(root / "scripts" / script).write_text(
f"import {{ build }} from '{relative}product.mjs'; build({step!r}, {output!r});\n",
encoding="utf-8",
)
(root / "scripts/build/package-desktop.mjs").write_text(
"import { relative } from 'node:path';\n"
"import { build } from '../../product.mjs';\n"
"const flag = '-c.directories.output=';\n"
"const staging = process.argv.find(arg => arg.startsWith(flag)).slice(flag.length);\n"
"build('desktop', relative('../..', staging) + '/linux-unpacked/hermes');\n",
encoding="utf-8",
)
(root / "scripts/build/build-desktop.mjs").write_text(
"if (!process.argv.includes('--icons')) await import('../generate-icons.mjs');\n",
encoding="utf-8",
)
return root, acquired
def _events(root):
path = root / "events.jsonl"
return [json.loads(line) for line in path.read_text().splitlines()] if path.exists() else []
@pytest.mark.platforms("posix")
def test_preparation_reuses_only_the_exact_completed_workspace_union(source_checkout):
from hermes_cli.source_build import prepare_source_dependencies, source_build_env
root, acquired = source_checkout
before = (root / "package-lock.json").read_bytes()
env = source_build_env()
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
first = _events(root)
assert [event["step"] for event in first] == ["deps"]
assert first[0]["python"] == sys.executable
assert first[0]["ci"] == "1"
assert "esbuild" not in first[0]
assert first[0]["npmrc"] == str(Path(os.environ["HERMES_HOME"]) / "npmrc")
assert (root / "node_modules/ui-tui").exists()
assert (root / "node_modules/web").exists()
assert not (root / "node_modules/apps-desktop").exists()
assert not (root / "node_modules/unrelated").exists()
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert _events(root) == first
prepare_source_dependencies(root, ("ui-tui", "web", "apps/desktop"), env=env)
assert len(_events(root)) == 2
assert (root / "node_modules/apps-desktop").exists()
assert not (root / "node_modules/unrelated").exists()
assert (root / "package-lock.json").read_bytes() == before
assert acquired == ["npm"]
# A lock failure must not fall back to npm install and rewrite the lock.
(root / "package-lock.json").write_text("not json", encoding="utf-8")
with pytest.raises(subprocess.CalledProcessError):
prepare_source_dependencies(root, ("ui-tui", "web"), env=env)
assert (root / "package-lock.json").read_text() == "not json"
assert len(_events(root)) == 2
assert acquired == ["npm"]
# Exercise PM rather than hiding a provisioning error behind system npm.
from unittest.mock import patch
with patch.object(pm, "ensure", side_effect=pm.InstallError("npm", "unavailable")):
with pytest.raises(pm.InstallError, match="unavailable"):
source_build_env()
assert len(_events(root)) == 2
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("desktop", [False, True])
def test_update_builds_selected_products_after_one_union_preparation(source_products, desktop):
from hermes_cli.source_build import build_update_products
from hermes_cli.main_web_build import _web_ui_build_needed
root, acquired = source_products
app = root / "apps/desktop/release/linux-unpacked/hermes"
app.parent.mkdir(parents=True)
app.write_text("previous app")
build_update_products(root, desktop=desktop)
steps = [event["step"] for event in _events(root)]
assert steps == ["deps", "tui", "icons", "web"] + (["desktop"] if desktop else [])
assert acquired == ["npm"]
assert (root / "ui-tui/dist/entry.js").read_text() == "tui"
assert (root / "hermes_cli/web_dist/index.html").read_text() == "web"
assert not _web_ui_build_needed(root / "web")
assert (root / "node_modules/apps-desktop").exists() == desktop
assert not (root / "node_modules/unrelated").exists()
assert app.read_text() == ("desktop" if desktop else "previous app")
assert not list((root / "apps/desktop").glob(".staging-*"))
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("step", ["tui", "icons", "web", "desktop"])
def test_update_failure_raises_without_retries_or_replacing_live_app(source_products, step):
from hermes_cli.source_build import build_update_products
root, acquired = source_products
app = root / "apps/desktop/release/linux-unpacked/hermes"
app.parent.mkdir(parents=True)
app.write_text("previous app")
(root / f"fail-{step}").touch()
with pytest.raises(subprocess.CalledProcessError):
build_update_products(root, desktop=True)
assert app.read_text() == "previous app"
assert not list((root / "apps/desktop").glob(".staging-*"))
order = ["deps", "tui", "icons", "web", "desktop"]
assert [event["step"] for event in _events(root)] == order[:order.index(step) + 1]
assert acquired == ["npm"]
assert not (Path(os.environ["HERMES_HOME"]) / "desktop-build-stamp.json").exists()
@pytest.mark.platforms("linux")
@pytest.mark.parametrize("desktop", [False, True])
def test_module_cli_builds_the_requested_products(source_products, desktop, monkeypatch):
import runpy
root, acquired = source_products
monkeypatch.setattr(sys, "argv", ["source_build", "--source", str(root)] + (["--desktop"] if desktop else []))
# run_module exercises __main__ while substituting only tool acquisition.
monkeypatch.delitem(sys.modules, "hermes_cli.source_build", raising=False)
runpy.run_module("hermes_cli.source_build", run_name="__main__")
assert acquired == ["npm"]
assert (root / "hermes_cli/web_dist/index.html").is_file()
assert (root / "apps/desktop/release/linux-unpacked/hermes").exists() == desktop