From 6f16603b9c0b6275352d90b6e573668a4b3431d1 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Fri, 19 Jun 2026 12:23:46 -0400 Subject: [PATCH] fix(desktop): pre-fetch Electron dist after clean install on update (#47917) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hermes update` reinstalls repo-root deps with --workspaces=false, so the desktop workspace is never reinstalled on update and electron's dist-fetching postinstall never runs. node_modules/electron/dist is therefore empty after a successful `npm ci`, and electron-builder silently falls through to a fresh @electron/get download on every update ("no local electron dist; will fetch") — a slow network round-trip, or a hard failure where the release host is blocked. This is the residual behind the #47917 'resurfaced' reports after the dynamic-electronDist fix (#48091). cmd_gui already self-heals after a pack *fails*, but never proactively on the install-success path. Add a pre-build check: when npm ci succeeds but electron is staged with an empty dist/, repopulate it (canonical host, then mirror) before the first pack so the build reuses the local binary. Tests: pre-fetch runs before the first pack when dist/ is empty; is skipped when dist/ is healthy. --- hermes_cli/main.py | 19 +++++++ tests/hermes_cli/test_gui_command.py | 77 ++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 039eb5d449c42..07f23816a2ea1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5461,6 +5461,25 @@ def cmd_gui(args: argparse.Namespace): print(" ⚠ Dependency install failed with a missing Electron dist; " "continuing to the build so electron-builder can attempt " "the Electron fetch itself.") + elif not source_mode and _electron_pkg_staged_missing_dist(PROJECT_ROOT): + # `npm ci` succeeded but left ``node_modules/electron/dist`` empty. + # This is the steady-state cost of ``hermes update`` reinstalling + # repo-root deps with ``--workspaces=false`` (it does NOT reinstall + # the desktop workspace, so electron's postinstall — which fetches + # dist/ — never runs on update; #47917). electron-builder would + # then fall through to a fresh ``@electron/get`` download on every + # single update ("no local electron dist; …will fetch"), turning a + # near-no-op rebuild into a slow network round-trip — or a hard + # failure where GitHub's release host is blocked. Repopulate dist/ + # proactively (canonical host, then mirror) so the first pack reuses + # the local binary instead of racing the network. + if _try_redownload_electron_dist(PROJECT_ROOT, env): + print(" ⚠ Electron dist was missing after install (update reinstalls " + "repo-root deps only); repopulated it before building.") + else: + print(" ⚠ Electron dist missing after install and could not be " + "pre-fetched; continuing so electron-builder can attempt " + "the Electron fetch itself.") build_label = "source build" if source_mode else "packaged app" print(f"→ Building desktop {build_label}...") diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 4995a7897ac7a..a2dd9e760aad5 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -246,6 +246,83 @@ def test_gui_source_mode_uses_renderer_build_and_electron(tmp_path, monkeypatch) assert mock_run.call_args_list[1].kwargs["cwd"] == desktop_dir +def test_gui_prefetches_electron_dist_after_clean_install_before_pack(tmp_path, monkeypatch): + """`npm ci` succeeds but leaves dist/ empty -> repopulate BEFORE the first pack. + + This is the #47917 residual: `hermes update` reinstalls repo-root deps with + ``--workspaces=false``, so electron's dist-fetching postinstall never runs on + update and ``node_modules/electron/dist`` is empty even though the install + succeeded. Without a proactive repair, electron-builder silently falls + through to a fresh ``@electron/get`` download on every update. The first pack + must only run after dist/ has been repopulated. + """ + 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) + + call_order: list[str] = [] + + def _record_prefetch(*_a, **_kw): + call_order.append("prefetch") + return True + + def _record_run(cmd, *_a, **_kw): + if cmd[:3] == ["/usr/bin/npm", "run", "pack"]: + call_order.append("pack") + 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_build_needed", return_value=True), \ + patch("hermes_cli.main._electron_pkg_staged_missing_dist", return_value=True), \ + patch("hermes_cli.main._try_redownload_electron_dist", side_effect=_record_prefetch) as mock_prefetch, \ + patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \ + patch("hermes_cli.main._write_desktop_build_stamp"), \ + patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main.subprocess.run", side_effect=_record_run), \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 0 + mock_prefetch.assert_called_once() + # The repair must happen BEFORE the first pack, not as after-failure recovery. + assert call_order[:2] == ["prefetch", "pack"] + + +def test_gui_skips_electron_prefetch_when_dist_present(tmp_path, monkeypatch): + """Healthy dist/ after install -> no redundant pre-fetch, straight to pack.""" + 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), \ + patch("hermes_cli.main._desktop_build_needed", return_value=True), \ + patch("hermes_cli.main._electron_pkg_staged_missing_dist", return_value=False), \ + patch("hermes_cli.main._try_redownload_electron_dist") as mock_prefetch, \ + patch("hermes_cli.main._stop_desktop_processes_locking_build", return_value=[]), \ + 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 + mock_prefetch.assert_not_called() + assert mock_run.call_args_list[0].args[0] == ["/usr/bin/npm", "run", "pack"] + + @pytest.mark.parametrize( "argv", [