diff --git a/hermes_cli/main_desktop.py b/hermes_cli/main_desktop.py index 77ab523861..d4135170d1 100644 --- a/hermes_cli/main_desktop.py +++ b/hermes_cli/main_desktop.py @@ -1454,6 +1454,11 @@ def _run_desktop_pack_with_recovery( A MISSING exe is the signature of the corrupt-download class; a late failure (e.g. macOS signing) leaves it in place and a redownload retry would only repeat the same slow failure. + + Both rungs additionally require the Electron distributable to be MISSING. + "No staged exe" is also true of every failure before electron-builder ever + runs (compile, bundler, native link), and switching mirrors cannot repair + those — it just re-runs the whole pack behind a message blaming GitHub. """ from hermes_cli.main import PROJECT_ROOT def _staged_exe() -> Optional[Path]: @@ -1487,13 +1492,13 @@ def _run_desktop_pack_with_recovery( build_result.returncode != 0 and staging_dir is not None and not env.get("ELECTRON_MIRROR") - and _staged_exe() is None): + and _staged_exe() is None + and not _electron_dist_ok(PROJECT_ROOT)): print(" ⚠ Desktop build still failing; the Electron download from " "GitHub looks blocked. Re-downloading via a public mirror " "(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)") mirror_env = {**npm_build_env, "ELECTRON_MIRROR": _ELECTRON_FALLBACK_MIRROR} - if not _electron_dist_ok(PROJECT_ROOT): - _redownload_electron_dist(PROJECT_ROOT, env, mirror=_ELECTRON_FALLBACK_MIRROR) + _redownload_electron_dist(PROJECT_ROOT, env, mirror=_ELECTRON_FALLBACK_MIRROR) _stop_desktop_processes_locking_build(desktop_dir) build_result = _pack(mirror_env) return build_result diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index 529d56c339..fad296587a 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -337,6 +337,115 @@ def test_gui_does_not_retry_after_packaged_executable_exists(tmp_path, monkeypat assert "Desktop GUI build failed" in capsys.readouterr().out +def _make_electron_dist(root: Path) -> Path: + """Lay a complete Electron install where ``_electron_dist_ok`` looks on THIS host. + + Resolved through ``_electron_dist_binary()`` rather than a hardcoded path so + the fixture and the code agree by construction on every OS lane. + """ + binary = main_desktop._electron_dist_binary(root) + binary.parent.mkdir(parents=True, exist_ok=True) + binary.write_text("", encoding="utf-8") + return binary + + +def _pack_that_never_reaches_the_builder(): + """A ``subprocess.run`` stand-in for a pack that dies before electron-builder. + + It lays NOTHING down in the staging output — that is the signature of a + compile, bundler or native-link failure — and records each attempt's env so + a test can see whether the mirror rung was entered. + """ + attempts: list[dict] = [] + + def _run(cmd, **kwargs): + if len(cmd) >= 3 and cmd[1:3] == ["run", "pack"]: + attempts.append(dict(kwargs.get("env") or {})) + return subprocess.CompletedProcess(cmd, 1) + return subprocess.CompletedProcess(cmd, 0) + + return attempts, _run + + +def test_gui_skips_mirror_retry_when_electron_dist_is_intact(tmp_path, monkeypatch, capsys): + """A pack that never reached electron-builder has no mirror problem to repair. + + "The pack failed and the staging output holds no exe" is ALSO true of a + compile / bundler / native-link failure, so on its own it cannot select the + mirror rung — yet that rung re-ran the whole pack while telling the user the + Electron download from GitHub looked blocked. The mirror retry additionally + requires the Electron distributable to be missing. + """ + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_electron_dist(root) + live_exe = _make_packaged_executable(root, monkeypatch) + live_exe.write_text("good build", encoding="utf-8") + monkeypatch.delenv("ELECTRON_MIRROR", raising=False) + + attempts, run_pack = _pack_that_never_reaches_the_builder() + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]) as mock_purge, \ + patch("hermes_cli.main_desktop._redownload_electron_dist", return_value=True) as mock_dl, \ + patch("hermes_cli.main.subprocess.run", side_effect=run_pack), \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + # One pack, no mirror env on it, no false accusation of a blocked download. + assert len(attempts) == 1 + assert not any(a.get("ELECTRON_MIRROR") for a in attempts) + assert "looks blocked" not in capsys.readouterr().out + # Nothing recovery-shaped ran either. + mock_purge.assert_not_called() + mock_dl.assert_not_called() + + +def test_gui_still_retries_via_mirror_when_electron_dist_is_missing(tmp_path, monkeypatch, capsys): + """The blocked-download recovery the mirror rung exists for still fires. + + Guard so a later tightening of the gate cannot silently drop the last rung: + Electron staged its package but ``dist`` was never populated, the pack + produces no exe, and the user hasn't pinned a mirror — refresh via + npmmirror.com and pack once more. + """ + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + # electron present, dist half-populated (a blocked postinstall download). + (root / "node_modules" / "electron" / "dist").mkdir(parents=True) + live_exe = _make_packaged_executable(root, monkeypatch) + live_exe.write_text("good build", encoding="utf-8") + monkeypatch.delenv("ELECTRON_MIRROR", raising=False) + + attempts, run_pack = _pack_that_never_reaches_the_builder() + install_ok = subprocess.CompletedProcess(["npm", "ci"], 0) + + with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \ + patch("hermes_cli.main_web_build._run_npm_install_deterministic", return_value=install_ok), \ + patch("hermes_cli.main_desktop._desktop_macos_relaunchable_fixup"), \ + patch("hermes_cli.main_desktop._purge_electron_build_cache", return_value=[]) as mock_purge, \ + patch("hermes_cli.main_desktop._redownload_electron_dist", return_value=True) as mock_dl, \ + patch("hermes_cli.main.subprocess.run", side_effect=run_pack), \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + # The whole ladder still runs: pack, refreshed-download retry, mirror retry. + assert len(attempts) == 3 + assert not attempts[0].get("ELECTRON_MIRROR") + assert not attempts[1].get("ELECTRON_MIRROR") + assert attempts[2].get("ELECTRON_MIRROR") == main_desktop._ELECTRON_FALLBACK_MIRROR + assert "looks blocked" in capsys.readouterr().out + # And the mirror run reached the dist re-download, mirror in hand. + assert mock_purge.called + assert any(a.kwargs.get("mirror") == main_desktop._ELECTRON_FALLBACK_MIRROR + for a in mock_dl.call_args_list) + + # ── electronDist (re)download helper tests (#47266) ─────────────────── diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index bb55d9807e..384416abe8 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -596,7 +596,7 @@ clearing the entry — the latch resets and the next boot dials fresh. The build downloads the Electron runtime (~114 MB) from `github.com/electron/electron/releases`. If the installer hangs on the **Build desktop app** step with the live output repeating `retrying attempt=…`, GitHub is being blocked or throttled on your network (firewall, proxy, or region). -The installer self-heals this automatically: on a failed build it (1) clears a corrupt cached Electron zip and retries, then (2) if it still fails and you haven't set `ELECTRON_MIRROR`, retries once more through `npmmirror.com`, the de-facto Electron community mirror. `@electron/get` SHASUM-checks the download, but the checksums come from the same mirror — that catches a corrupt or partial download, not a compromised mirror. If you'd rather not trust a third-party host, pin your own `ELECTRON_MIRROR` (below); the build never overrides one you've set. +The installer self-heals this automatically: on a failed build it (1) clears a corrupt cached Electron zip and retries, then (2) if it still fails, the Electron distributable is still missing, and you haven't set `ELECTRON_MIRROR`, retries once more through `npmmirror.com`, the de-facto Electron community mirror. `@electron/get` SHASUM-checks the download, but the checksums come from the same mirror — that catches a corrupt or partial download, not a compromised mirror. If you'd rather not trust a third-party host, pin your own `ELECTRON_MIRROR` (below); the build never overrides one you've set. To **choose your own mirror** (e.g. a corporate/trusted one), set `ELECTRON_MIRROR` before installing or rebuild manually — the build honors it and won't override it: