feat(desktop): content-hash build stamp with --build-only and --force-build flags

Add a SHA-256 content-hash based build stamp to `hermes desktop` so
unchanged source trees skip the npm install + build step. Uses pathspec
for .gitignore-aware file matching instead of a hardcoded skip-list.

New CLI flags:
- --build-only: run the build but don't launch the app
- --force-build: rebuild even when the stamp matches

`hermes update` now calls `hermes desktop --build-only` so the
desktop app is rebuilt (if needed) as part of the update flow.

16/16 tests passing.
This commit is contained in:
ethernet 2026-06-02 15:30:00 -04:00
parent b34ee80741
commit c2050183a5
5 changed files with 396 additions and 42 deletions

View file

@ -16,6 +16,8 @@ from hermes_cli import main as cli_main
def _ns(**kw):
defaults = dict(
skip_build=False,
build_only=False,
force_build=False,
source=False,
fake_boot=False,
ignore_existing=False,
@ -60,6 +62,8 @@ def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch):
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
@ -86,6 +90,8 @@ def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch):
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[ok, ok]) as mock_run, \
pytest.raises(SystemExit):
@ -158,6 +164,8 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_build_needed", return_value=True), \
patch("hermes_cli.main._write_desktop_build_stamp"), \
patch("hermes_cli.main.subprocess.run", side_effect=[build_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(source=True))
@ -179,3 +187,158 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch)
def test_gui_is_known_builtin_for_plugin_gating(argv):
with patch.object(sys, "argv", argv):
assert cli_main._plugin_cli_discovery_needed() is False
# ── Content-hash stamp tests ──────────────────────────────────────────
def test_desktop_build_stamp_skips_build_when_up_to_date(tmp_path, monkeypatch):
"""When the stamp matches and the artifact exists, build is skipped entirely."""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch)
launch_ok = subprocess.CompletedProcess([], 0)
with patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 0
mock_install.assert_not_called()
mock_run.assert_called_once() # only the launch call, no build
def test_desktop_force_build_overrides_stamp(tmp_path, monkeypatch):
"""--force-build forces a rebuild even when the stamp says up-to-date."""
root = _make_desktop_tree(tmp_path)
desktop_dir = root / "apps" / "desktop"
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch)
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
launch_ok = subprocess.CompletedProcess([], 0)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok) as mock_install, \
patch("hermes_cli.main._desktop_build_needed", return_value=False), \
patch("hermes_cli.main._write_desktop_build_stamp") as mock_stamp, \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_ok, launch_ok]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns(force_build=True))
assert exc.value.code == 0
mock_install.assert_called_once()
mock_stamp.assert_called_once()
# pack + launch = 2 calls
assert mock_run.call_count == 2
def test_compute_desktop_content_hash_stable(tmp_path, monkeypatch):
"""_compute_desktop_content_hash returns the same digest for identical trees."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("console.log('hi')", encoding="utf-8")
(root / "package.json").write_text('{"name":"hermes"}', encoding="utf-8")
(root / "package-lock.json").write_text('{}', encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
h1 = cli_main._compute_desktop_content_hash(root)
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 == h2
assert len(h1) == 64 # sha256 hex
def test_compute_desktop_content_hash_changes_on_edit(tmp_path, monkeypatch):
"""Editing a file under apps/desktop/ changes the hash."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
h1 = cli_main._compute_desktop_content_hash(root)
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 != h2
def test_desktop_build_needed_detects_missing_artifact(tmp_path, monkeypatch):
"""Even with a valid stamp, missing artifact means build is needed."""
root = _make_desktop_tree(tmp_path)
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Write a stamp that matches current content
cli_main._write_desktop_build_stamp(root, source_mode=False)
# No packaged executable exists → build needed
assert cli_main._desktop_build_needed(
root / "apps" / "desktop", root, source_mode=False
) is True
def test_desktop_build_stamp_round_trip(tmp_path, monkeypatch):
"""Write stamp, then _desktop_build_needed returns False when artifact exists."""
root = _make_desktop_tree(tmp_path)
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Create the artifact so the "artifact exists" check passes
_make_packaged_executable(root, monkeypatch)
# Write stamp
cli_main._write_desktop_build_stamp(root, source_mode=False)
# Build should NOT be needed
assert cli_main._desktop_build_needed(
root / "apps" / "desktop", root, source_mode=False
) is False
def test_compute_desktop_content_hash_works_without_gitignore(tmp_path, monkeypatch):
"""When no .gitignore exists, _compute_desktop_content_hash still works (matches everything)."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("v1", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# No .gitignore → pathspec matches nothing → all files hashed
h = cli_main._compute_desktop_content_hash(root)
assert len(h) == 64 # valid sha256 hex
# Edit a file → hash changes
(root / "apps" / "desktop" / "main.js").write_text("v2", encoding="utf-8")
h2 = cli_main._compute_desktop_content_hash(root)
assert h != h2
def test_compute_desktop_content_hash_respects_gitignore(tmp_path, monkeypatch):
"""Files matched by .gitignore are excluded from the hash."""
root = _make_desktop_tree(tmp_path)
(root / "apps" / "desktop" / "main.js").write_text("hello", encoding="utf-8")
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=xxx", encoding="utf-8")
(root / "package.json").write_text("{}", encoding="utf-8")
(root / "package-lock.json").write_text("{}", encoding="utf-8")
(root / ".gitignore").write_text("*.env\n", encoding="utf-8")
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
# Reset cached spec
cli_main._DESKTOP_STAMP_SPEC = None
h1 = cli_main._compute_desktop_content_hash(root)
# Change the .env file (ignored) — hash should NOT change
(root / "apps" / "desktop" / "secrets.env").write_text("API_KEY=yyy", encoding="utf-8")
cli_main._DESKTOP_STAMP_SPEC = None # reset since gitignore hasn't changed
h2 = cli_main._compute_desktop_content_hash(root)
assert h1 == h2, "changing an ignored file should not change the hash"
# Change the .js file (not ignored) — hash SHOULD change
(root / "apps" / "desktop" / "main.js").write_text("world", encoding="utf-8")
cli_main._DESKTOP_STAMP_SPEC = None
h3 = cli_main._compute_desktop_content_hash(root)
assert h1 != h3, "changing a tracked file should change the hash"