Files
hermes-agent/tests/hermes_cli/test_source_build.py
ethernet 4b7229d612 fix(icons): commit generated icons; installs and regular builds never render
User installs failed with 'resvg-py is missing' because the web/desktop
source builds rendered icons on whatever python was on PATH. The default
brand outputs are now committed; source_build, apps/desktop build.mjs and
the npm/docusaurus pre-hooks consume them directly. Flavored release
bundles (canary/commit) still render into their own product dir.

icons-freshness-check now regenerates and fails on any byte diff.
2026-09-24 19:17:34 -04:00

302 lines
14 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_source_build_uses_selected_python_for_isolated_icon_child(tmp_path, monkeypatch):
from hermes_cli.source_build import source_build_env
from pm import paths
from pm.environments import site_packages, venv_python
root = tmp_path / "source"
root.mkdir()
venv = root / "venv"
subprocess.run([sys.executable, "-m", "venv", "--without-pip", str(venv)], check=True)
selected = site_packages(venv)
(selected / "icon_dependency.py").write_text("ready = True\n", encoding="utf-8")
monkeypatch.setattr(paths, "repo_root", lambda: root)
monkeypatch.setattr(pm, "ensure", lambda name, **kwargs: Runner(name, kwargs["base_env"]))
monkeypatch.syspath_prepend(str(selected))
env = source_build_env()
assert env["HERMES_PYTHON"] == str(venv_python(venv))
subprocess.run([env["HERMES_PYTHON"], "-I", "-c",
"import icon_dependency; assert icon_dependency.ready"], 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", "never-rendered-at-install"),
("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",
)
# Default-brand icons are committed; updates consume them without rendering.
(root / "web/public").mkdir(parents=True, exist_ok=True)
(root / "web/public/favicon.ico").write_bytes(b"committed icon")
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"] == env["HERMES_PYTHON"]
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", "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", "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", "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