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:
parent
b34ee80741
commit
c2050183a5
5 changed files with 396 additions and 42 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue