mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Hardened-runtime restrictions are enforced even for ad-hoc signatures, so signing with --options runtime without the allow-jit entitlements would leave Electron/V8 crashing on launch — strictly worse than the legacy plain ad-hoc sign. Raise instead, so the fixup falls back to the legacy path and the bundle always stays launchable.
1335 lines
60 KiB
Python
1335 lines
60 KiB
Python
"""Tests for ``hermes gui`` desktop launcher wiring."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
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,
|
|
hermes_root=None,
|
|
cwd=None,
|
|
)
|
|
defaults.update(kw)
|
|
return argparse.Namespace(**defaults)
|
|
|
|
|
|
def _make_desktop_tree(tmp_path: Path) -> Path:
|
|
root = tmp_path / "hermes-agent"
|
|
desktop_dir = root / "apps" / "desktop"
|
|
desktop_dir.mkdir(parents=True)
|
|
(desktop_dir / "package.json").write_text("{}", encoding="utf-8")
|
|
return root
|
|
|
|
|
|
def _make_packaged_executable(root: Path, monkeypatch, platform: str = "darwin") -> Path:
|
|
monkeypatch.setattr(cli_main.sys, "platform", platform)
|
|
desktop_dir = root / "apps" / "desktop"
|
|
if platform == "darwin":
|
|
exe = desktop_dir / "release" / "mac-arm64" / "Hermes.app" / "Contents" / "MacOS" / "Hermes"
|
|
elif platform == "win32":
|
|
exe = desktop_dir / "release" / "win-unpacked" / "Hermes.exe"
|
|
else:
|
|
exe = desktop_dir / "release" / "linux-unpacked" / "hermes"
|
|
exe.parent.mkdir(parents=True)
|
|
exe.write_text("", encoding="utf-8")
|
|
return exe
|
|
|
|
|
|
def test_gui_installs_packages_and_launches_desktop_app(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
desktop_dir = root / "apps" / "desktop"
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
packaged_exe = _make_packaged_executable(root, monkeypatch)
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
|
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 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=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:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 0
|
|
# The install now runs with a resolved env (managed-Node PATH), never a bare
|
|
# ``env=None`` that would leave npm's child scripts unable to find ``node``.
|
|
mock_install.assert_called_once()
|
|
assert mock_install.call_args.args == ("/usr/bin/npm", root)
|
|
assert mock_install.call_args.kwargs["capture_output"] is False
|
|
install_env = mock_install.call_args.kwargs["env"]
|
|
assert install_env is not None and "PATH" in install_env
|
|
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"]
|
|
assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir
|
|
assert mock_run.call_args_list[1].args[0] == [str(packaged_exe)]
|
|
assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir
|
|
|
|
|
|
def test_gui_install_env_prepends_managed_node_on_bare_path(tmp_path, monkeypatch):
|
|
"""Regression: npm's child scripts (electron-winstaller's select-7z-arch.js)
|
|
shell out to bare ``node``. When Desktop is launched from the updater chain
|
|
the parent PATH is stripped, so the install env MUST carry the Hermes-managed
|
|
Node ahead of that bare PATH or the install dies with ``node: not found``.
|
|
"""
|
|
import os
|
|
|
|
from hermes_constants import iter_hermes_node_dirs
|
|
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
_make_packaged_executable(root, monkeypatch, platform="win32")
|
|
|
|
# A managed Node tree on disk so with_hermes_node_path() actually prepends it.
|
|
home = tmp_path / "hermes-home"
|
|
(home / "node" / "bin").mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
# Simulate the stripped PATH the desktop updater chain hands us.
|
|
monkeypatch.setenv("PATH", os.pathsep.join(["/usr/bin", "/bin"]))
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
launch_ok = subprocess.CompletedProcess(["hermes"], 0)
|
|
|
|
with patch("hermes_cli.main._resolve_node_runtime_npm", 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.subprocess.run", side_effect=[subprocess.CompletedProcess([], 0), launch_ok]), \
|
|
pytest.raises(SystemExit):
|
|
cli_main.cmd_gui(_ns(skip_build=False))
|
|
|
|
managed_dirs = [str(p) for p in iter_hermes_node_dirs() if p.is_dir()]
|
|
assert managed_dirs, "managed node tree not discovered"
|
|
install_env = mock_install.call_args.kwargs["env"]
|
|
path_parts = install_env["PATH"].split(os.pathsep)
|
|
assert path_parts[: len(managed_dirs)] == managed_dirs
|
|
assert "/usr/bin" in path_parts # the bare updater PATH is preserved, just after managed Node
|
|
|
|
|
|
def test_gui_forwards_desktop_environment_overrides(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
hermes_root = tmp_path / "custom-hermes"
|
|
cwd = tmp_path / "project"
|
|
hermes_root.mkdir()
|
|
cwd.mkdir()
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
_make_packaged_executable(root, monkeypatch)
|
|
|
|
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=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):
|
|
cli_main.cmd_gui(_ns(
|
|
fake_boot=True,
|
|
ignore_existing=True,
|
|
hermes_root=str(hermes_root),
|
|
cwd=str(cwd),
|
|
))
|
|
|
|
launch_env = mock_run.call_args_list[1].kwargs["env"]
|
|
assert launch_env["HERMES_DESKTOP_BOOT_FAKE"] == "1"
|
|
assert launch_env["HERMES_DESKTOP_IGNORE_EXISTING"] == "1"
|
|
assert launch_env["HERMES_DESKTOP_HERMES_ROOT"] == str(hermes_root)
|
|
assert launch_env["HERMES_DESKTOP_CWD"] == str(cwd)
|
|
|
|
|
|
def test_gui_exits_when_npm_missing(tmp_path, monkeypatch, capsys):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value=None), \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
assert "npm was not found" in capsys.readouterr().out
|
|
|
|
|
|
def test_gui_skip_build_requires_existing_packaged_app(tmp_path, monkeypatch, capsys):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
|
|
with pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns(skip_build=True))
|
|
|
|
assert exc.value.code == 1
|
|
assert "no packaged desktop app" in capsys.readouterr().out
|
|
|
|
|
|
def test_gui_skip_build_launches_existing_packaged_app_without_npm(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
desktop_dir = root / "apps" / "desktop"
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
packaged_exe = _make_packaged_executable(root, monkeypatch)
|
|
|
|
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value=None), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic") as mock_install, \
|
|
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns(skip_build=True))
|
|
|
|
assert exc.value.code == 0
|
|
mock_install.assert_not_called()
|
|
mock_run.assert_called_once()
|
|
assert mock_run.call_args.args[0] == [str(packaged_exe)]
|
|
|
|
|
|
def test_gui_linux_configures_sandbox_before_launch(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
|
sandbox = packaged_exe.parent / "chrome-sandbox"
|
|
sandbox.write_text("", encoding="utf-8")
|
|
sandbox.chmod(0o755)
|
|
ok = subprocess.CompletedProcess([], 0)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
|
patch("hermes_cli.main.subprocess.run", return_value=ok) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns(skip_build=True))
|
|
|
|
assert exc.value.code == 0
|
|
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/sudo", "chown", "root:root", str(sandbox)]
|
|
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/sudo", "chmod", "4755", str(sandbox)]
|
|
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
|
|
|
|
|
|
def test_gui_linux_rejects_symlink_sandbox(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
|
# Point chrome-sandbox at an unrelated file via symlink
|
|
target = tmp_path / "dangerous"
|
|
target.write_text("pwned", encoding="utf-8")
|
|
sandbox = packaged_exe.parent / "chrome-sandbox"
|
|
sandbox.symlink_to(target)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
|
patch("hermes_cli.main.subprocess.run") as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns(skip_build=True))
|
|
|
|
assert exc.value.code == 1
|
|
# Must NOT have called sudo chown/chmod on the symlink target
|
|
for call in mock_run.call_args_list:
|
|
assert "chown" not in call.args[0]
|
|
assert "chmod" not in call.args[0]
|
|
|
|
|
|
def test_gui_linux_skips_fixup_when_already_configured(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
|
sandbox = packaged_exe.parent / "chrome-sandbox"
|
|
sandbox.write_text("", encoding="utf-8")
|
|
# Simulate root-owned 4755 — lstat().st_uid==0 and mode==0o4755
|
|
# We can't actually chown to root in tests, so mock lstat to return
|
|
# the expected values directly.
|
|
import stat as stat_mod
|
|
fake_stat = type("s", (), {"st_uid": 0, "st_mode": 0o4755 | stat_mod.S_IFREG})()
|
|
sandbox_lstat_orig = type(sandbox).lstat
|
|
monkeypatch.setattr(type(sandbox), "lstat", lambda self: fake_stat)
|
|
|
|
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/sudo"), \
|
|
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns(skip_build=True))
|
|
|
|
assert exc.value.code == 0
|
|
# Only the launch call — no sudo chown/chmod
|
|
mock_run.assert_called_once()
|
|
assert mock_run.call_args.args[0] == [str(packaged_exe)]
|
|
|
|
|
|
def test_gui_linux_falls_back_to_no_sandbox_when_userns_is_restricted(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
|
sandbox = packaged_exe.parent / "chrome-sandbox"
|
|
sandbox.write_text("", encoding="utf-8")
|
|
|
|
launch_ok = subprocess.CompletedProcess([str(packaged_exe), "--no-sandbox"], 0)
|
|
|
|
with patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=False), \
|
|
patch("hermes_cli.main._desktop_linux_needs_no_sandbox", return_value=True), \
|
|
patch("hermes_cli.main.subprocess.run", return_value=launch_ok) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns(skip_build=True))
|
|
|
|
assert exc.value.code == 0
|
|
mock_run.assert_called_once()
|
|
assert mock_run.call_args.args[0] == [str(packaged_exe), "--no-sandbox"]
|
|
|
|
|
|
def test_gui_linux_exits_when_sandbox_fixup_fails_without_safe_fallback(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
_make_packaged_executable(root, monkeypatch, platform="linux")
|
|
|
|
with patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=False), \
|
|
patch("hermes_cli.main._desktop_linux_needs_no_sandbox", return_value=False), \
|
|
patch("hermes_cli.main.subprocess.run") as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns(skip_build=True))
|
|
|
|
assert exc.value.code == 1
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
desktop_dir = root / "apps" / "desktop"
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
build_ok = subprocess.CompletedProcess(["npm", "run", "build"], 0)
|
|
launch_ok = subprocess.CompletedProcess(["npm", "exec", "--", "electron", "."], 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), \
|
|
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))
|
|
|
|
assert exc.value.code == 0
|
|
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "build"]
|
|
assert mock_run.call_args_list[0].kwargs["cwd"] == desktop_dir
|
|
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "exec", "--", "electron", "."]
|
|
assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"argv",
|
|
[
|
|
["hermes", "gui"],
|
|
["hermes", "-m", "gpt5", "gui"],
|
|
],
|
|
)
|
|
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"
|
|
|
|
|
|
# ── Electron build-cache recovery tests ───────────────────────────────
|
|
|
|
|
|
def _write_zip(path: Path) -> None:
|
|
import zipfile
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
with zipfile.ZipFile(path, "w") as zf:
|
|
zf.writestr("electron", "fake binary payload")
|
|
|
|
|
|
def test_purge_electron_build_cache_clears_all_zips_and_unpacked_dir(tmp_path, monkeypatch):
|
|
"""Purge is unconditional: it removes every electron-*.zip (regardless of
|
|
whether stdlib zipfile thinks it's corrupt) plus the half-written unpacked
|
|
dir, because @electron/get's own SHASUM check on re-download is the real
|
|
validator — not a self-rolled one."""
|
|
cache = tmp_path / "electron-cache"
|
|
# A "clean" zip and a prepended-junk zip — the latter is the real-world
|
|
# corruption that zipfile.testzip() silently passes (it reads from the
|
|
# end-of-central-directory backward), which is why we don't gate on it.
|
|
clean = cache / "electron-v40.9.3-linux-x64.zip"
|
|
prepended = cache / "hashdir" / "electron-v40.9.3-linux-x64.zip"
|
|
_write_zip(clean)
|
|
_write_zip(prepended)
|
|
prepended.write_bytes(b"\x00" * 4096 + prepended.read_bytes())
|
|
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
unpacked = desktop_dir / "release" / "linux-unpacked"
|
|
unpacked.mkdir(parents=True)
|
|
(unpacked / "LICENSE.electron.txt").write_text("x", encoding="utf-8")
|
|
(unpacked / "resources.pak").write_text("x", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
|
|
|
|
removed = cli_main._purge_electron_build_cache(desktop_dir)
|
|
|
|
assert clean in removed
|
|
assert prepended in removed
|
|
assert unpacked in removed
|
|
assert not clean.exists()
|
|
assert not prepended.exists()
|
|
assert not unpacked.exists()
|
|
|
|
|
|
def test_purge_electron_build_cache_empty_when_nothing_present(tmp_path, monkeypatch):
|
|
"""No cached zips and no unpacked dir → nothing removed, so the caller
|
|
knows a retry is pointless."""
|
|
cache = tmp_path / "electron-cache"
|
|
cache.mkdir()
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
monkeypatch.setattr(cli_main, "_electron_download_cache_dirs", lambda: [cache])
|
|
|
|
assert cli_main._purge_electron_build_cache(desktop_dir) == []
|
|
|
|
|
|
def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch):
|
|
"""First pack fails with NO packaged executable (corrupt-download shape),
|
|
purge clears the cache, second pack succeeds and produces the exe, launch."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
# Executable is ABSENT at build-failure time — that is the corrupt-download
|
|
# signature the cache purge + retry exist for (#40187). Only the successful
|
|
# retry produces it (via the side_effect below).
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
packaged_exe = root / "apps" / "desktop" / "release" / "linux-unpacked" / "hermes"
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
|
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
|
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
|
|
|
_calls = {"n": 0}
|
|
|
|
def run_side_effect(*args, **kwargs):
|
|
_calls["n"] += 1
|
|
if _calls["n"] == 1:
|
|
return pack_fail
|
|
if _calls["n"] == 2:
|
|
# Successful retry materializes the packaged executable.
|
|
packaged_exe.parent.mkdir(parents=True, exist_ok=True)
|
|
packaged_exe.write_text("", encoding="utf-8")
|
|
return pack_ok
|
|
return launch_ok
|
|
|
|
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_macos_relaunchable_fixup"), \
|
|
patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \
|
|
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
|
patch("hermes_cli.main._purge_electron_build_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \
|
|
patch("hermes_cli.main._electron_dist_ok", return_value=False), \
|
|
patch("hermes_cli.main._redownload_electron_dist", return_value=True), \
|
|
patch("hermes_cli.main.subprocess.run", side_effect=run_side_effect) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 0
|
|
mock_purge.assert_called_once()
|
|
# pack(fail) → repair succeeds → pack(ok) → launch = 3 subprocess.run calls
|
|
assert mock_run.call_count == 3
|
|
assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"]
|
|
assert mock_run.call_args_list[1].args[0] == ["/usr/bin/npm", "run", "pack"]
|
|
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
|
|
|
|
|
|
def test_gui_redownloads_electron_via_mirror_then_repacks(tmp_path, monkeypatch, capsys):
|
|
"""Purge clears nothing and the pinned electronDist (#38673) is missing →
|
|
the mirror fallback must drive electron's own downloader (NOT another pack,
|
|
which never downloads Electron) and only then retry pack (#47266)."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
# No packaged executable: the corrupt-download recovery must run.
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
monkeypatch.delenv("ELECTRON_MIRROR", raising=False)
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
|
|
|
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_macos_relaunchable_fixup"), \
|
|
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
|
|
patch("hermes_cli.main._electron_dist_ok", return_value=False), \
|
|
patch("hermes_cli.main._redownload_electron_dist", side_effect=[False, True]) as mock_dl, \
|
|
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_fail]) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
# initial pack + mirror pack = 2 npm calls. The first-retry pack is skipped
|
|
# because the canonical-source re-download (no mirror) failed, so there was
|
|
# never a binary to build against.
|
|
assert mock_run.call_count == 2
|
|
# First re-download attempt is canonical (no mirror); the second drives the
|
|
# public mirror.
|
|
assert mock_dl.call_args_list[0].kwargs.get("mirror") is None
|
|
assert mock_dl.call_args_list[1].kwargs["mirror"]
|
|
# Only the mirror-driven pack carries ELECTRON_MIRROR.
|
|
assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {})
|
|
assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"]
|
|
assert "Desktop GUI build failed" in capsys.readouterr().out
|
|
|
|
|
|
def test_gui_retries_pack_under_mirror_even_when_prefetch_blocked(tmp_path, monkeypatch, capsys):
|
|
"""When electron's own downloader can't fetch the binary (even via the
|
|
mirror), still retry pack under ELECTRON_MIRROR: the build resolves
|
|
electronDist dynamically and lets electron-builder fetch Electron itself
|
|
via @electron/get, which honors the mirror. That retry is no longer
|
|
pointless (it was, back when electronDist was a static path)."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
# No packaged executable: the corrupt-download recovery must run.
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
monkeypatch.delenv("ELECTRON_MIRROR", raising=False)
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
|
|
|
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_macos_relaunchable_fixup"), \
|
|
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]), \
|
|
patch("hermes_cli.main._electron_dist_ok", return_value=False), \
|
|
patch("hermes_cli.main._redownload_electron_dist", return_value=False), \
|
|
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_fail]) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
# Initial pack + mirror-driven pack = 2; the mirror retry runs even though
|
|
# the pre-fetch failed, so electron-builder gets a shot at downloading.
|
|
assert mock_run.call_count == 2
|
|
assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {})
|
|
assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"]
|
|
assert "Desktop GUI build failed" in capsys.readouterr().out
|
|
|
|
|
|
def test_gui_install_failure_self_heals_electron_and_continues(tmp_path, monkeypatch, capsys):
|
|
"""npm ci failing on electron's blocked binary download must NOT abort the
|
|
install: with the electron package staged, repopulate its dist and continue
|
|
to the build instead of sys.exit-ing before pack ever runs (#47266/#48021)."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
packaged_exe = _make_packaged_executable(root, monkeypatch, platform="linux")
|
|
# electron package staged on disk (postinstall download was the casualty).
|
|
(root / "apps" / "desktop" / "node_modules" / "electron").mkdir(parents=True)
|
|
(root / "apps" / "desktop" / "node_modules" / "electron" / "package.json").write_text("{}", encoding="utf-8")
|
|
(root / "apps" / "desktop" / "node_modules" / "electron" / "install.js").write_text("", encoding="utf-8")
|
|
|
|
install_fail = subprocess.CompletedProcess(["npm", "ci"], 1)
|
|
pack_ok = subprocess.CompletedProcess(["npm", "run", "pack"], 0)
|
|
launch_ok = subprocess.CompletedProcess([str(packaged_exe)], 0)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_fail), \
|
|
patch("hermes_cli.main._desktop_linux_sandbox_fixup", return_value=True), \
|
|
patch("hermes_cli.main._write_desktop_build_stamp"), \
|
|
patch("hermes_cli.main._electron_dist_ok", return_value=False), \
|
|
patch("hermes_cli.main._try_redownload_electron_dist", return_value=True) as mock_dl, \
|
|
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())
|
|
|
|
assert exc.value.code == 0
|
|
mock_dl.assert_called() # tried to repopulate the dist
|
|
# pack + launch ran — the install failure did NOT abort the build.
|
|
assert mock_run.call_count == 2
|
|
assert "repopulated" in capsys.readouterr().out.lower()
|
|
|
|
|
|
def test_gui_install_failure_hard_fails_when_electron_not_staged(tmp_path, monkeypatch, capsys):
|
|
"""A dependency-install failure where electron never even staged is a genuine
|
|
error (not a blocked binary download) — hard-fail with guidance, don't try to
|
|
self-heal a tree that isn't there."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
_make_packaged_executable(root, monkeypatch, platform="linux")
|
|
|
|
install_fail = subprocess.CompletedProcess(["npm", "ci"], 1)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_fail), \
|
|
patch("hermes_cli.main.subprocess.run") as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
mock_run.assert_not_called() # build never started
|
|
assert "Desktop dependency install failed" in capsys.readouterr().out
|
|
|
|
|
|
def test_gui_install_failure_hard_fails_when_electron_dist_exists(tmp_path, monkeypatch, capsys):
|
|
"""If npm install fails but Electron dist is already present, don't classify
|
|
it as the blocked-download shape; fail fast as a generic install error."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
_make_packaged_executable(root, monkeypatch, platform="linux")
|
|
electron_dir = root / "apps" / "desktop" / "node_modules" / "electron"
|
|
electron_dir.mkdir(parents=True)
|
|
(electron_dir / "package.json").write_text("{}", encoding="utf-8")
|
|
(electron_dir / "install.js").write_text("", encoding="utf-8")
|
|
|
|
install_fail = subprocess.CompletedProcess(["npm", "ci"], 1)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
|
|
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_fail), \
|
|
patch("hermes_cli.main._electron_dist_ok", return_value=True), \
|
|
patch("hermes_cli.main.subprocess.run") as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
mock_run.assert_not_called()
|
|
assert "Desktop dependency install failed" in capsys.readouterr().out
|
|
|
|
|
|
def test_gui_does_not_retry_after_packaged_executable_exists(tmp_path, monkeypatch, capsys):
|
|
"""A build that already produced a packaged executable did NOT fail from the
|
|
Electron-download problem the cache purge + mirror retries exist to repair.
|
|
|
|
Regression for #40187: a late failure such as macOS code signing leaves
|
|
Hermes.app/Contents/MacOS/Hermes in place. Re-downloading Electron can't
|
|
repair a signing failure, so the destructive purge + slow mirror retry must
|
|
be skipped — we fail directly instead of grinding through an identical retry.
|
|
"""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
# Executable EXISTS at failure time → late failure, not a corrupt download.
|
|
_make_packaged_executable(root, monkeypatch, platform="darwin")
|
|
monkeypatch.delenv("ELECTRON_MIRROR", raising=False)
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
|
|
|
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_macos_relaunchable_fixup"), \
|
|
patch("hermes_cli.main._purge_electron_build_cache", return_value=[Path("/c/electron.zip")]) as mock_purge, \
|
|
patch("hermes_cli.main._redownload_electron_dist", return_value=True) as mock_dl, \
|
|
patch("hermes_cli.main.subprocess.run", return_value=pack_fail) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
# Neither destructive recovery runs, and there is exactly ONE pack attempt.
|
|
mock_purge.assert_not_called()
|
|
mock_dl.assert_not_called()
|
|
assert mock_run.call_count == 1
|
|
assert "Desktop GUI build failed" in capsys.readouterr().out
|
|
|
|
|
|
def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsys):
|
|
"""A user-pinned ELECTRON_MIRROR is respected: no extra mirror fallback
|
|
attempt (and we never swap in our default mirror)."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
# No packaged executable: the build failure is the download-class the
|
|
# mirror fallback handles (and we assert the user's pin is respected).
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
monkeypatch.setenv("ELECTRON_MIRROR", "https://mirror.example/electron/")
|
|
|
|
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
|
|
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
|
|
|
|
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_macos_relaunchable_fixup"), \
|
|
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
|
|
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail]) as mock_run, \
|
|
pytest.raises(SystemExit) as exc:
|
|
cli_main.cmd_gui(_ns())
|
|
|
|
assert exc.value.code == 1
|
|
mock_purge.assert_called_once()
|
|
assert mock_run.call_count == 1
|
|
assert mock_run.call_args_list[0].kwargs["env"]["ELECTRON_MIRROR"] == "https://mirror.example/electron/"
|
|
assert "Desktop GUI build failed" in capsys.readouterr().out
|
|
|
|
|
|
# ── electronDist (re)download helper tests (#47266) ───────────────────
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"platform,rel",
|
|
[
|
|
("linux", "dist/electron"),
|
|
("win32", "dist/electron.exe"),
|
|
("darwin", "dist/Electron.app/Contents/MacOS/Electron"),
|
|
],
|
|
)
|
|
def test_electron_dist_ok_per_platform(tmp_path, monkeypatch, platform, rel):
|
|
monkeypatch.setattr(cli_main.sys, "platform", platform)
|
|
electron = tmp_path / "node_modules" / "electron"
|
|
# A dist dir that exists but lacks the binary is NOT ok (partial extraction).
|
|
(electron / "dist").mkdir(parents=True)
|
|
assert cli_main._electron_dist_ok(tmp_path) is False
|
|
|
|
binp = electron / rel
|
|
binp.parent.mkdir(parents=True, exist_ok=True)
|
|
binp.write_text("", encoding="utf-8")
|
|
assert cli_main._electron_dist_ok(tmp_path) is True
|
|
|
|
|
|
def test_electron_dir_prefers_workspace_local_package(tmp_path):
|
|
"""npm may nest electron under apps/desktop; resolve there over the root hoist."""
|
|
root_electron = tmp_path / "node_modules" / "electron"
|
|
local_electron = tmp_path / "apps" / "desktop" / "node_modules" / "electron"
|
|
root_electron.mkdir(parents=True)
|
|
local_electron.mkdir(parents=True)
|
|
|
|
assert cli_main._electron_dir(tmp_path) == local_electron
|
|
|
|
|
|
def test_electron_dir_falls_back_to_root_hoist(tmp_path):
|
|
"""When npm hoists electron to the repo root, resolve there."""
|
|
root_electron = tmp_path / "node_modules" / "electron"
|
|
root_electron.mkdir(parents=True)
|
|
|
|
assert cli_main._electron_dir(tmp_path) == root_electron
|
|
|
|
|
|
def test_electron_dist_ok_finds_workspace_local_binary(tmp_path, monkeypatch):
|
|
"""A nested apps/desktop electron with a valid binary counts as ok."""
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
binp = tmp_path / "apps" / "desktop" / "node_modules" / "electron" / "dist" / "electron"
|
|
binp.parent.mkdir(parents=True)
|
|
binp.write_text("", encoding="utf-8")
|
|
assert cli_main._electron_dist_ok(tmp_path) is True
|
|
|
|
|
|
def test_redownload_electron_dist_noop_when_present(tmp_path, monkeypatch):
|
|
"""Already-healthy dist → no download, so an unrelated build failure can't
|
|
trigger a needless ~200 MB refetch."""
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
binp = tmp_path / "node_modules" / "electron" / "dist" / "electron"
|
|
binp.parent.mkdir(parents=True)
|
|
binp.write_text("", encoding="utf-8")
|
|
|
|
with patch("hermes_cli.main.subprocess.run") as mock_run:
|
|
assert cli_main._redownload_electron_dist(tmp_path, {}) is True
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_redownload_electron_dist_missing_installer(tmp_path, monkeypatch):
|
|
"""No electron/install.js (deps never installed) → nothing to run."""
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
(tmp_path / "node_modules" / "electron").mkdir(parents=True)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/node"), \
|
|
patch("hermes_cli.main.subprocess.run") as mock_run:
|
|
assert cli_main._redownload_electron_dist(tmp_path, {}) is False
|
|
mock_run.assert_not_called()
|
|
|
|
|
|
def test_redownload_electron_dist_runs_installer_with_mirror(tmp_path, monkeypatch):
|
|
"""Missing dist → wipe any partial dist + version marker, run electron's own
|
|
install.js with ELECTRON_MIRROR injected, and report success on the binary."""
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
electron = tmp_path / "node_modules" / "electron"
|
|
electron.mkdir(parents=True)
|
|
(electron / "install.js").write_text("// stub", encoding="utf-8")
|
|
# A stale partial dist + version marker that MUST be cleared first, otherwise
|
|
# electron's install.js short-circuits on path.txt and never re-downloads.
|
|
(electron / "dist").mkdir()
|
|
(electron / "dist" / "leftover").write_text("junk", encoding="utf-8")
|
|
(electron / "path.txt").write_text("electron", encoding="utf-8")
|
|
|
|
captured = {}
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["env"] = kwargs.get("env")
|
|
captured["cwd"] = kwargs.get("cwd")
|
|
# simulate electron's install.js producing the dist binary
|
|
binp = electron / "dist" / "electron"
|
|
binp.parent.mkdir(parents=True, exist_ok=True)
|
|
binp.write_text("", encoding="utf-8")
|
|
return subprocess.CompletedProcess(cmd, 0)
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/node"), \
|
|
patch("hermes_cli.main.subprocess.run", side_effect=fake_run):
|
|
ok = cli_main._redownload_electron_dist(
|
|
tmp_path, {"PATH": "/x"}, mirror="https://mirror.example/electron/"
|
|
)
|
|
|
|
assert ok is True
|
|
assert captured["cmd"] == ["/usr/bin/node", str(electron / "install.js")]
|
|
assert captured["cwd"] == str(electron)
|
|
assert captured["env"]["ELECTRON_MIRROR"] == "https://mirror.example/electron/"
|
|
# The partial dir + marker were dropped before the re-download.
|
|
assert not (electron / "dist" / "leftover").exists()
|
|
assert not (electron / "path.txt").exists()
|
|
|
|
|
|
def test_redownload_electron_dist_returns_false_when_download_fails(tmp_path, monkeypatch):
|
|
"""install.js ran but produced no binary (still blocked) → False, so the
|
|
caller skips a doomed pack."""
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
electron = tmp_path / "node_modules" / "electron"
|
|
electron.mkdir(parents=True)
|
|
(electron / "install.js").write_text("// stub", encoding="utf-8")
|
|
|
|
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/node"), \
|
|
patch("hermes_cli.main.subprocess.run",
|
|
return_value=subprocess.CompletedProcess(["node"], 1)):
|
|
assert cli_main._redownload_electron_dist(tmp_path, {}) is False
|
|
|
|
|
|
class _FakeProc:
|
|
"""Minimal psutil.Process stand-in for the lock-breaker tests."""
|
|
|
|
def __init__(self, pid: int, exe: str | None):
|
|
self.pid = pid
|
|
self.info = {"pid": pid, "exe": exe}
|
|
self.terminated = False
|
|
self.killed = False
|
|
|
|
def terminate(self):
|
|
self.terminated = True
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
|
|
def test_stop_desktop_build_lock_noop_off_windows(tmp_path, monkeypatch):
|
|
"""POSIX can unlink a running binary, so the helper is a no-op there."""
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
exe = desktop_dir / "release" / "linux-unpacked" / "hermes"
|
|
exe.parent.mkdir(parents=True)
|
|
exe.write_text("", encoding="utf-8")
|
|
monkeypatch.setattr(cli_main.sys, "platform", "linux")
|
|
|
|
proc = _FakeProc(4321, str(exe))
|
|
with patch("psutil.process_iter", return_value=[proc]) as it:
|
|
assert cli_main._stop_desktop_processes_locking_build(desktop_dir) == []
|
|
it.assert_not_called()
|
|
assert proc.terminated is False
|
|
|
|
|
|
def test_stop_desktop_build_lock_terminates_only_release_procs(tmp_path, monkeypatch):
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
release = desktop_dir / "release" / "win-unpacked"
|
|
release.mkdir(parents=True)
|
|
locker_exe = release / "Hermes.exe"
|
|
locker_exe.write_text("", encoding="utf-8")
|
|
other_exe = tmp_path / "elsewhere" / "Hermes.exe"
|
|
other_exe.parent.mkdir(parents=True)
|
|
other_exe.write_text("", encoding="utf-8")
|
|
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
monkeypatch.setattr(cli_main.os, "getpid", lambda: 999)
|
|
|
|
locker = _FakeProc(101, str(locker_exe))
|
|
unrelated = _FakeProc(102, str(other_exe))
|
|
selfish = _FakeProc(999, str(locker_exe)) # our own PID — never killed
|
|
no_exe = _FakeProc(103, None)
|
|
|
|
captured = {}
|
|
|
|
def _wait(procs, timeout=None):
|
|
captured["waited"] = list(procs)
|
|
return procs, []
|
|
|
|
with patch("psutil.process_iter", return_value=[locker, unrelated, selfish, no_exe]), \
|
|
patch("psutil.wait_procs", side_effect=_wait):
|
|
stopped = cli_main._stop_desktop_processes_locking_build(desktop_dir)
|
|
|
|
assert stopped == [101]
|
|
assert locker.terminated is True
|
|
assert unrelated.terminated is False
|
|
assert selfish.terminated is False
|
|
assert captured["waited"] == [locker]
|
|
|
|
|
|
def test_stop_desktop_build_lock_no_release_dir(tmp_path, monkeypatch):
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
desktop_dir.mkdir(parents=True)
|
|
monkeypatch.setattr(cli_main.sys, "platform", "win32")
|
|
with patch("psutil.process_iter") as it:
|
|
assert cli_main._stop_desktop_processes_locking_build(desktop_dir) == []
|
|
it.assert_not_called()
|
|
|
|
|
|
def test_force_adhoc_signing_disables_discovery_on_local_packaged_rebuild(monkeypatch):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
env = {}
|
|
assert cli_main._force_adhoc_macos_signing(env, source_mode=False) is True
|
|
assert env["CSC_IDENTITY_AUTO_DISCOVERY"] == "false"
|
|
|
|
|
|
@pytest.mark.parametrize("platform", ["linux", "win32"])
|
|
def test_force_adhoc_signing_noop_off_macos(monkeypatch, platform):
|
|
monkeypatch.setattr(cli_main.sys, "platform", platform)
|
|
env = {}
|
|
assert cli_main._force_adhoc_macos_signing(env, source_mode=False) is False
|
|
assert "CSC_IDENTITY_AUTO_DISCOVERY" not in env
|
|
|
|
|
|
def test_force_adhoc_signing_noop_for_source_mode(monkeypatch):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
env = {}
|
|
assert cli_main._force_adhoc_macos_signing(env, source_mode=True) is False
|
|
assert "CSC_IDENTITY_AUTO_DISCOVERY" not in env
|
|
|
|
|
|
@pytest.mark.parametrize("key", ["CSC_LINK", "APPLE_SIGNING_IDENTITY"])
|
|
def test_force_adhoc_signing_preserves_real_identity(monkeypatch, key):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
env = {key: "secret"}
|
|
assert cli_main._force_adhoc_macos_signing(env, source_mode=False) is False
|
|
assert "CSC_IDENTITY_AUTO_DISCOVERY" not in env
|
|
|
|
|
|
def test_force_adhoc_signing_respects_explicit_caller_flag(monkeypatch):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
env = {"CSC_IDENTITY_AUTO_DISCOVERY": "true"}
|
|
assert cli_main._force_adhoc_macos_signing(env, source_mode=False) is False
|
|
assert env["CSC_IDENTITY_AUTO_DISCOVERY"] == "true"
|
|
|
|
|
|
# --- macOS TCC-stable local signing (relaunch fixup) -----------------------
|
|
|
|
|
|
def _write_info_plist(bundle: Path, identifier: str) -> None:
|
|
import plistlib
|
|
|
|
info = bundle / "Contents" / "Info.plist"
|
|
info.parent.mkdir(parents=True, exist_ok=True)
|
|
info.write_bytes(plistlib.dumps({"CFBundleIdentifier": identifier}))
|
|
|
|
|
|
def _make_signable_app(desktop_dir: Path) -> Path:
|
|
"""Build a fake packaged Hermes.app with the pieces the signer must find."""
|
|
ent_dir = desktop_dir / "electron"
|
|
ent_dir.mkdir(parents=True, exist_ok=True)
|
|
(ent_dir / "entitlements.mac.plist").write_text("<plist/>", encoding="utf-8")
|
|
(ent_dir / "entitlements.mac.inherit.plist").write_text("<plist/>", encoding="utf-8")
|
|
|
|
app = desktop_dir / "release" / "mac-arm64" / "Hermes.app"
|
|
_write_info_plist(app, "com.nousresearch.hermes")
|
|
(app / "Contents" / "MacOS").mkdir(parents=True)
|
|
(app / "Contents" / "MacOS" / "Hermes").write_text("", encoding="utf-8")
|
|
|
|
helper = app / "Contents" / "Frameworks" / "Hermes Helper.app"
|
|
_write_info_plist(helper, "com.nousresearch.hermes.helper")
|
|
|
|
native_dir = app / "Contents" / "Resources" / "app.asar.unpacked" / "node_modules" / "pty"
|
|
native_dir.mkdir(parents=True)
|
|
(native_dir / "pty.node").write_text("", encoding="utf-8")
|
|
(app / "Contents" / "Frameworks" / "chrome_crashpad_handler").write_text("", encoding="utf-8")
|
|
return app
|
|
|
|
|
|
def _collect_codesign_calls(monkeypatch):
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append(list(cmd))
|
|
return subprocess.CompletedProcess(cmd, 0)
|
|
|
|
monkeypatch.setattr(
|
|
cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None
|
|
)
|
|
monkeypatch.setattr(cli_main.subprocess, "run", fake_run)
|
|
return calls
|
|
|
|
|
|
def test_desktop_macos_local_codesign_signs_native_binaries(tmp_path, monkeypatch):
|
|
"""The standalone Mach-O pass must actually find files inside the bundle.
|
|
|
|
Regression: an absolute-path parts check always matches the outer
|
|
Hermes.app component, silently skipping every .node/.dylib/crashpad
|
|
binary — codesign then rejects the outer signature (nested code unsigned).
|
|
"""
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
app = _make_signable_app(desktop_dir)
|
|
calls = _collect_codesign_calls(monkeypatch)
|
|
|
|
assert cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is True
|
|
|
|
signed = [c[-1] for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]]
|
|
assert str(app / "Contents" / "Resources" / "app.asar.unpacked" / "node_modules" / "pty" / "pty.node") in signed
|
|
assert str(app / "Contents" / "Frameworks" / "chrome_crashpad_handler") in signed
|
|
|
|
|
|
def test_desktop_macos_local_codesign_stable_requirements_and_entitlements(tmp_path, monkeypatch):
|
|
"""Ad-hoc signing must not leave the bundle with a cdhash-only identity.
|
|
|
|
TCC pins grants to the Designated Requirement; cdhash-only DRs churn on
|
|
every rebuild. Each bundle gets an explicit identifier requirement, the
|
|
main app keeps its entitlements, helpers keep the inherit entitlements,
|
|
and the whole thing is strictly verified at the end.
|
|
"""
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
app = _make_signable_app(desktop_dir)
|
|
ent_main = desktop_dir / "electron" / "entitlements.mac.plist"
|
|
ent_inherit = desktop_dir / "electron" / "entitlements.mac.inherit.plist"
|
|
calls = _collect_codesign_calls(monkeypatch)
|
|
|
|
assert cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is True
|
|
|
|
sign_calls = [c for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]]
|
|
assert any(
|
|
'=designated => identifier "com.nousresearch.hermes"' in c and str(ent_main) in c
|
|
for c in sign_calls
|
|
)
|
|
assert any(
|
|
'=designated => identifier "com.nousresearch.hermes.helper"' in c and str(ent_inherit) in c
|
|
for c in sign_calls
|
|
)
|
|
assert calls[-1][:4] == ["/usr/bin/codesign", "--verify", "--deep", "--strict"]
|
|
|
|
|
|
def test_desktop_macos_local_codesign_keychain_identity_skips_requirements(tmp_path, monkeypatch):
|
|
"""A real cert anchors the DR by itself; no explicit requirement injected."""
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
app = _make_signable_app(desktop_dir)
|
|
calls = _collect_codesign_calls(monkeypatch)
|
|
|
|
assert cli_main._desktop_macos_local_codesign(
|
|
app, desktop_dir=desktop_dir, identity="Hermes Local Signing"
|
|
) is True
|
|
|
|
sign_calls = [c for c in calls if c[:3] == ["/usr/bin/codesign", "--force", "--sign"]]
|
|
assert sign_calls, "expected sign invocations"
|
|
assert all(c[3] == "Hermes Local Signing" for c in sign_calls)
|
|
assert all("--requirements" not in c for c in sign_calls)
|
|
|
|
|
|
def test_desktop_macos_local_codesign_false_without_codesign(tmp_path, monkeypatch):
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
app = _make_signable_app(desktop_dir)
|
|
monkeypatch.setattr(cli_main.shutil, "which", lambda name: None)
|
|
assert cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir) is False
|
|
|
|
|
|
def test_desktop_macos_local_codesign_refuses_without_entitlements(tmp_path, monkeypatch):
|
|
"""Hardened runtime without allow-jit bricks Electron — must raise, not sign.
|
|
|
|
Hardened-runtime restrictions are enforced even for ad-hoc signatures, so
|
|
signing with --options runtime while the entitlement plists are missing
|
|
would produce a bundle that crashes on launch. The fixup catches the raise
|
|
and falls back to the legacy plain ad-hoc sign instead.
|
|
"""
|
|
desktop_dir = tmp_path / "apps" / "desktop"
|
|
app = _make_signable_app(desktop_dir)
|
|
(desktop_dir / "electron" / "entitlements.mac.plist").unlink()
|
|
calls = _collect_codesign_calls(monkeypatch)
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
cli_main._desktop_macos_local_codesign(app, desktop_dir=desktop_dir)
|
|
assert calls == [] # nothing was signed with a runtime flag sans entitlements
|
|
|
|
|
|
def test_relaunchable_fixup_noop_when_publisher_signing_configured(tmp_path, monkeypatch):
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
monkeypatch.setenv("CSC_LINK", "publisher-cert")
|
|
|
|
with patch("hermes_cli.main.subprocess.run") as run:
|
|
assert cli_main._desktop_macos_relaunchable_fixup(root / "apps" / "desktop") is True
|
|
run.assert_not_called()
|
|
|
|
|
|
def test_relaunchable_fixup_explicit_publisher_decision_beats_environment(tmp_path, monkeypatch):
|
|
"""A caller that already decided publisher signing wins over later env state."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
monkeypatch.delenv("CSC_LINK", raising=False)
|
|
monkeypatch.setenv("APPLE_SIGNING_IDENTITY", "loaded-later-from-dotenv")
|
|
|
|
with patch("hermes_cli.main.subprocess.run") as run:
|
|
assert cli_main._desktop_macos_relaunchable_fixup(
|
|
root / "apps" / "desktop", publisher_signing_configured=True
|
|
) is True
|
|
run.assert_not_called()
|
|
|
|
|
|
def test_relaunchable_fixup_never_clobbers_valid_developer_id_signature(tmp_path, monkeypatch):
|
|
"""A bundle with an intact Team ID signature must be left untouched."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
desktop_dir = root / "apps" / "desktop"
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
monkeypatch.delenv("CSC_LINK", raising=False)
|
|
monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False)
|
|
exe = _make_packaged_executable(root, monkeypatch, platform="darwin")
|
|
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append(list(cmd))
|
|
if cmd[:2] == ["/usr/bin/codesign", "-dv"]:
|
|
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="TeamIdentifier=T2F6S8MF7C\n")
|
|
return subprocess.CompletedProcess(cmd, 0)
|
|
|
|
monkeypatch.setattr(
|
|
cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None
|
|
)
|
|
monkeypatch.setattr(cli_main.subprocess, "run", fake_run)
|
|
|
|
assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is True
|
|
assert all(c[:3] != ["/usr/bin/codesign", "--force", "--sign"] for c in calls)
|
|
assert all(c[0] != "xattr" for c in calls)
|
|
|
|
|
|
def test_relaunchable_fixup_falls_back_to_legacy_adhoc_on_failure(tmp_path, monkeypatch, capsys):
|
|
"""A failing stable sign must still leave a launchable (deep ad-hoc) bundle."""
|
|
root = _make_desktop_tree(tmp_path)
|
|
desktop_dir = root / "apps" / "desktop"
|
|
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
|
|
monkeypatch.delenv("CSC_LINK", raising=False)
|
|
monkeypatch.delenv("APPLE_SIGNING_IDENTITY", raising=False)
|
|
exe = _make_packaged_executable(root, monkeypatch, platform="darwin")
|
|
app = exe.parents[2]
|
|
|
|
calls = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
calls.append(list(cmd))
|
|
return subprocess.CompletedProcess(cmd, 0)
|
|
|
|
monkeypatch.setattr(
|
|
cli_main.shutil, "which", lambda name: "/usr/bin/codesign" if name == "codesign" else None
|
|
)
|
|
monkeypatch.setattr(cli_main.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(cli_main, "_desktop_macos_has_valid_real_signature", lambda a: False)
|
|
monkeypatch.setattr(cli_main, "_desktop_macos_local_signing_identity", lambda: None)
|
|
|
|
def boom(*a, **kw):
|
|
raise subprocess.CalledProcessError(1, ["codesign"])
|
|
|
|
monkeypatch.setattr(cli_main, "_desktop_macos_local_codesign", boom)
|
|
|
|
assert cli_main._desktop_macos_relaunchable_fixup(desktop_dir) is False
|
|
assert ["xattr", "-cr", str(app)] in calls
|
|
assert ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(app)] in calls
|
|
|
|
|
|
def test_desktop_macos_local_signing_identity_reads_config(monkeypatch):
|
|
monkeypatch.setattr(cli_main.sys, "platform", "darwin")
|
|
with patch(
|
|
"hermes_cli.config.load_config",
|
|
return_value={"desktop": {"macos_signing_identity": " Hermes Local Signing "}},
|
|
):
|
|
assert cli_main._desktop_macos_local_signing_identity() == "Hermes Local Signing"
|
|
with patch("hermes_cli.config.load_config", return_value={"desktop": {}}):
|
|
assert cli_main._desktop_macos_local_signing_identity() is None
|
|
|
|
|
|
# --- desktop.* launch options (config.yaml) -------------------------------
|
|
|
|
|
|
def test_desktop_launch_options_defaults_when_no_config():
|
|
with patch("hermes_cli.config.load_config", return_value={}):
|
|
flags, gpu = cli_main._desktop_launch_options()
|
|
assert flags == []
|
|
assert gpu == "auto"
|
|
|
|
|
|
def test_desktop_launch_options_reads_flags_list():
|
|
cfg = {"desktop": {"electron_flags": ["--ozone-platform=x11", "--disable-gpu"]}}
|
|
with patch("hermes_cli.config.load_config", return_value=cfg):
|
|
flags, gpu = cli_main._desktop_launch_options()
|
|
assert flags == ["--ozone-platform=x11", "--disable-gpu"]
|
|
assert gpu == "auto"
|
|
|
|
|
|
def test_desktop_launch_options_splits_flag_string():
|
|
cfg = {"desktop": {"electron_flags": "--ozone-platform=x11 --disable-gpu"}}
|
|
with patch("hermes_cli.config.load_config", return_value=cfg):
|
|
flags, _ = cli_main._desktop_launch_options()
|
|
assert flags == ["--ozone-platform=x11", "--disable-gpu"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,expected",
|
|
[
|
|
(True, "1"),
|
|
(False, "0"),
|
|
("true", "1"),
|
|
("off", "0"),
|
|
("auto", "auto"),
|
|
("garbage", "auto"),
|
|
],
|
|
)
|
|
def test_desktop_launch_options_normalizes_disable_gpu(raw, expected):
|
|
cfg = {"desktop": {"disable_gpu": raw}}
|
|
with patch("hermes_cli.config.load_config", return_value=cfg):
|
|
_, gpu = cli_main._desktop_launch_options()
|
|
assert gpu == expected
|
|
|
|
|
|
def test_desktop_launch_options_survives_config_error():
|
|
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")):
|
|
flags, gpu = cli_main._desktop_launch_options()
|
|
assert flags == []
|
|
assert gpu == "auto"
|