diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f70e6c6201e..1e5ef8fc3ea 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5110,6 +5110,90 @@ def _purge_electron_build_cache(desktop_dir: Path) -> list[Path]: return removed +def _electron_dist_binary(project_root: Path) -> Path: + """Return the path to the Electron main binary inside ``node_modules``. + + electron-builder reads the binary from ``build.electronDist`` + (``node_modules/electron/dist``) since #38673, so this is the exact file + whose absence makes a pack fail with "The specified electronDist does not + exist". The basename differs per OS (the platform Electron is named for the + host the build runs on). + """ + dist = project_root / "node_modules" / "electron" / "dist" + if sys.platform == "darwin": + return dist / "Electron.app" / "Contents" / "MacOS" / "Electron" + if sys.platform == "win32": + return dist / "electron.exe" + return dist / "electron" + + +def _electron_dist_ok(project_root: Path) -> bool: + """True when ``node_modules/electron/dist`` holds a usable Electron binary. + + A directory that exists but is missing the binary (a partial extraction from + a corrupt cached zip, or an interrupted postinstall) counts as NOT ok, since + that is exactly the shape that makes electron-builder throw on the pinned + electronDist. + """ + try: + return _electron_dist_binary(project_root).exists() + except OSError: + return False + + +def _redownload_electron_dist( + project_root: Path, + env: dict, + *, + mirror: Optional[str] = None, +) -> bool: + """(Re)populate ``node_modules/electron/dist`` via electron's own downloader. + + Since #38673 the desktop build pins ``build.electronDist`` to + ``node_modules/electron/dist``, so electron-builder reads the Electron binary + straight from there and never downloads it during ``npm run pack``. That dist + tree is produced by the ``electron`` package's postinstall (``install.js``) + during ``npm ci``. When that download is blocked or throttled (GitHub's + release host is unreachable in some regions — #47266), the dist is missing + and re-running ``pack`` only re-throws "The specified electronDist does not + exist". The mirror fallback therefore has to drive *this* downloader, not + another ``pack``. + + No-op (returns True) when the dist binary is already present, so an unrelated + build failure doesn't trigger a needless ~200 MB re-download. Otherwise drops + any partial dist + version marker (electron's install.js short-circuits when + ``path.txt`` already matches) and runs the downloader once, optionally via a + mirror. Best-effort: never raises. Returns True iff the dist binary exists + afterward. + """ + if _electron_dist_ok(project_root): + return True + + electron_dir = project_root / "node_modules" / "electron" + installer = electron_dir / "install.js" + if not installer.is_file(): + return False + node = shutil.which("node") + if not node: + return False + + dist_dir = electron_dir / "dist" + shutil.rmtree(dist_dir, ignore_errors=True) + try: + (electron_dir / "path.txt").unlink() + except OSError: + pass + + dl_env = dict(env) + if mirror: + dl_env["ELECTRON_MIRROR"] = mirror + try: + subprocess.run([node, str(installer)], cwd=str(electron_dir), env=dl_env, check=False) + except OSError: + return False + return _electron_dist_ok(project_root) + + def _stop_desktop_processes_locking_build(desktop_dir: Path) -> list[int]: """Terminate any running desktop app executing from this build's ``release`` dir so a rebuild can replace its (otherwise locked) executable. @@ -5364,8 +5448,18 @@ def cmd_gui(args: argparse.Namespace): # failure was something else, the clean re-download is harmless # and the retry fails the same way. purged = _purge_electron_build_cache(desktop_dir) - if purged: - print(" ⚠ Desktop build failed; cleared cached Electron download and retrying once...") + # electronDist is pinned to node_modules/electron/dist (#38673): + # electron-builder reads the Electron binary from there and `pack` + # never downloads it, so purging the cache + re-running pack can't + # by itself repopulate a missing/partial dist. When the dist is + # actually gone, re-run electron's own downloader so the retry has + # a binary to read. Gated on the dist check so an unrelated build + # failure (tsc/vite) doesn't trigger a pointless ~200 MB refetch. + restored = False + if not _electron_dist_ok(PROJECT_ROOT): + restored = _redownload_electron_dist(PROJECT_ROOT, env) + if purged or restored: + print(" ⚠ Desktop build failed; refreshed the Electron download and retrying once...") for p in purged: print(f" - {p}") # The purge can't remove a win-unpacked tree whose Hermes.exe @@ -5383,12 +5477,25 @@ def cmd_gui(args: argparse.Namespace): # trade-off we only make AFTER the canonical GitHub download has # failed, and we never override a user-pinned ELECTRON_MIRROR. print(" ⚠ Desktop build still failing; the Electron download from " - "GitHub looks blocked. Retrying once via a public mirror " + "GitHub looks blocked. Re-downloading via a public mirror " "(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)") + mirror = "https://npmmirror.com/mirrors/electron/" mirror_env = dict(env) - mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/" - _stop_desktop_processes_locking_build(desktop_dir) - build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False) + mirror_env["ELECTRON_MIRROR"] = mirror + # electronDist is pinned (#38673), so `npm run pack` never + # downloads Electron — the mirror only helps if it drives + # electron's own downloader. Re-fetch the binary through the + # mirror first; otherwise the retry just re-reads the same missing + # dist and re-throws "electronDist does not exist" (#47266). + have_dist = _electron_dist_ok(PROJECT_ROOT) + if not have_dist: + have_dist = _redownload_electron_dist(PROJECT_ROOT, env, mirror=mirror) + if have_dist: + _stop_desktop_processes_locking_build(desktop_dir) + build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False) + else: + print(" ✗ Could not re-download Electron from the mirror " + "(node_modules/electron/dist still missing)") if build_result.returncode != 0: print("✗ Desktop GUI build failed") print(f" Run manually: cd apps/desktop && npm run {build_script}") diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 01125ff4a7e..b4ee5796bad 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -2161,6 +2161,66 @@ function Clear-ElectronBuildCache { return $removed } +# True when node_modules\electron\dist holds a usable Electron binary. +# electron-builder reads the binary from build.electronDist +# (node_modules\electron\dist) since #38673, so this is the exact file whose +# absence makes a pack fail with "The specified electronDist does not exist". A +# dist dir that exists but is missing electron.exe (partial extraction / aborted +# postinstall) is NOT ok. +function Test-ElectronDist { + param([string]$InstallDir) + $distExe = Join-Path $InstallDir 'node_modules\electron\dist\electron.exe' + return (Test-Path -LiteralPath $distExe) +} + +# (Re)populate node_modules\electron\dist via electron's own downloader. +# +# Since #38673 the desktop build pins build.electronDist to +# node_modules\electron\dist, so electron-builder reads the Electron binary +# straight from there and never downloads it during `npm run pack`. That dist +# tree is produced by the electron package's postinstall (install.js) during +# `npm ci`. When that download is blocked/throttled (GitHub's release host is +# unreachable in some regions - #47266), dist is missing and re-running pack only +# re-throws "The specified electronDist does not exist". The mirror fallback +# therefore has to drive THIS downloader, not another pack. +# +# No-op (returns $true) when the dist binary is already present. Otherwise drops a +# partial dist + version marker (electron's install.js short-circuits when +# path.txt already matches) and runs the downloader once, optionally via a +# mirror. Best-effort: never throws. Returns $true iff the dist binary exists +# afterward. +function Restore-ElectronDist { + param([string]$InstallDir, [string]$Mirror) + if (Test-ElectronDist -InstallDir $InstallDir) { return $true } + + $electronDir = Join-Path $InstallDir 'node_modules\electron' + $distExe = Join-Path $electronDir 'dist\electron.exe' + $installer = Join-Path $electronDir 'install.js' + if (-not (Test-Path -LiteralPath $installer)) { return $false } + $node = Get-Command node -ErrorAction SilentlyContinue + if (-not $node) { return $false } + + $distDir = Join-Path $electronDir 'dist' + if (Test-Path -LiteralPath $distDir) { + Remove-Item -LiteralPath $distDir -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath (Join-Path $electronDir 'path.txt') -Force -ErrorAction SilentlyContinue + + $prevMirror = $env:ELECTRON_MIRROR + if ($Mirror) { $env:ELECTRON_MIRROR = $Mirror } + try { + # Out-Host so the downloader's progress shows on the console WITHOUT + # leaking into this function's return value (PowerShell returns every + # object left on the output stream, so a bare pipe here would make the + # boolean below ambiguous). + & $node.Source $installer 2>&1 | ForEach-Object { "$_" } | Out-Host + } catch { + } finally { + $env:ELECTRON_MIRROR = $prevMirror + } + return (Test-Path -LiteralPath $distExe) +} + function Install-Desktop { # Build apps/desktop into a launchable Hermes.exe. Only called from # Stage-Desktop, which is itself only included in the manifest when @@ -2310,8 +2370,19 @@ function Install-Desktop { # once; @electron/get re-downloads with its own SHASUM check. Without # this a corrupt download hard-fails the whole installer. $purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir) - if ($purged.Count -gt 0) { - Write-Warn "Desktop build failed - cleared cached Electron download, retrying once:" + # electronDist is pinned to node_modules\electron\dist (#38673): + # electron-builder reads the Electron binary from there and `pack` + # never downloads it, so purging the cache + re-running pack can't by + # itself repopulate a missing/partial dist. When the dist is actually + # gone, re-run electron's own downloader so the retry has a binary to + # read. Gated on the dist check so an unrelated build failure + # (tsc/vite) doesn't trigger a pointless ~200MB refetch. + $restored = $false + if (-not (Test-ElectronDist -InstallDir $InstallDir)) { + $restored = Restore-ElectronDist -InstallDir $InstallDir + } + if ($purged.Count -gt 0 -or $restored) { + Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:" foreach ($p in $purged) { Write-Info " - $p" } & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog $code = $LASTEXITCODE @@ -2326,14 +2397,23 @@ function Install-Desktop { # trade-off we only make AFTER the canonical GitHub download has failed, # and we never override a user-pinned ELECTRON_MIRROR. if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) { - $prevMirror = $env:ELECTRON_MIRROR - $env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/" + $mirror = "https://npmmirror.com/mirrors/electron/" Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked." - Write-Warn "Retrying once via a public Electron mirror ($($env:ELECTRON_MIRROR)):" + Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:" Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)" - & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog - $code = $LASTEXITCODE - $env:ELECTRON_MIRROR = $prevMirror + # electronDist is pinned (#38673), so `npm run pack` never downloads + # Electron - the mirror only helps if it drives electron's own + # downloader. Re-fetch the binary through the mirror first; otherwise + # the retry just re-reads the same missing dist and re-throws + # "The specified electronDist does not exist" (#47266). + $haveDist = Test-ElectronDist -InstallDir $InstallDir + if (-not $haveDist) { $haveDist = Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror } + if ($haveDist) { + & $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog + $code = $LASTEXITCODE + } else { + Write-Warn "Could not re-download Electron from the mirror (node_modules\electron\dist still missing)" + } } $ErrorActionPreference = $prevEAP if ($code -ne 0) { diff --git a/scripts/install.sh b/scripts/install.sh index b3b5f104e3d..b3387fc9c02 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2407,6 +2407,58 @@ _desktop_pack() { # failed, and we never override a user-pinned ELECTRON_MIRROR. DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/" +# True (returns 0) when node_modules/electron/dist holds a usable Electron +# binary. electron-builder reads the binary from build.electronDist +# (node_modules/electron/dist) since #38673, so this is the exact file whose +# absence makes a pack fail with "The specified electronDist does not exist". A +# dist dir that exists but is missing the binary (partial extraction / aborted +# postinstall) is NOT ok. $1 = the workspace root holding node_modules. +_electron_dist_ok() { + local install_dir="$1" + local electron_dir="$install_dir/node_modules/electron" + if [ "$OS" = "macos" ]; then + [ -e "$electron_dir/dist/Electron.app/Contents/MacOS/Electron" ] + else + [ -e "$electron_dir/dist/electron" ] + fi +} + +# (Re)populate node_modules/electron/dist via electron's own downloader. +# +# Since #38673 the desktop build pins build.electronDist to +# node_modules/electron/dist, so electron-builder reads the Electron binary +# straight from there and never downloads it during `npm run pack`. That dist +# tree is produced by the electron package's postinstall (install.js) during +# `npm ci`. When that download is blocked/throttled (GitHub's release host is +# unreachable in some regions - #47266), dist is missing and re-running pack only +# re-throws "The specified electronDist does not exist". The mirror fallback +# therefore has to drive THIS downloader, not another pack. +# +# No-op (returns 0) when the dist binary is already present. Otherwise drops a +# partial dist + version marker (electron's install.js short-circuits when +# path.txt already matches) and runs the downloader once. $1 = the workspace root +# holding node_modules; optional $2 = an ELECTRON_MIRROR base URL. Best-effort: +# returns 0 iff the dist binary exists afterward. +_restore_electron_dist() { + local install_dir="$1" + local mirror="${2:-}" + local electron_dir="$install_dir/node_modules/electron" + _electron_dist_ok "$install_dir" && return 0 + + [ -f "$electron_dir/install.js" ] || return 1 + command -v node >/dev/null 2>&1 || return 1 + + rm -rf "$electron_dir/dist" 2>/dev/null || true + rm -f "$electron_dir/path.txt" 2>/dev/null || true + + if [ -n "$mirror" ]; then + ( cd "$electron_dir" && ELECTRON_MIRROR="$mirror" node install.js ) || true + else + ( cd "$electron_dir" && node install.js ) || true + fi + _electron_dist_ok "$install_dir" +} + # Build apps/desktop into a launchable native app. Mirrors install.ps1's # Install-Desktop: a root-level npm install so the apps/* workspace resolves # the desktop's own deps (Electron ~150MB), then `npm run pack` @@ -2479,8 +2531,19 @@ install_desktop() { # (b) Corrupt cached Electron zip is the most common self-healable cause. local purged purged="$(clear_electron_build_cache "$desktop_dir")" - if [ -n "$purged" ]; then - log_warn "Desktop build failed; cleared cached Electron download and retrying once..." + # electronDist is pinned to node_modules/electron/dist (#38673): + # electron-builder reads the binary from there and `pack` never downloads + # it, so purging the cache + re-running pack can't by itself repopulate a + # missing/partial dist. When the dist is actually gone, re-run electron's + # own downloader so the retry has a binary to read. Gated on the dist + # check so an unrelated build failure (tsc/vite) doesn't trigger a + # pointless ~200MB refetch. + local restored=false + if ! _electron_dist_ok "$INSTALL_DIR"; then + if _restore_electron_dist "$INSTALL_DIR"; then restored=true; fi + fi + if [ -n "$purged" ] || [ "$restored" = true ]; then + log_warn "Desktop build failed; refreshed the Electron download and retrying once..." if _desktop_pack "$desktop_dir"; then pack_ok=true fi @@ -2488,14 +2551,26 @@ install_desktop() { fi # (c) Still failing and the user hasn't pinned their own mirror: the GitHub - # release host is likely blocked/throttled. Retry once via a public - # Electron mirror (@electron/get still SHASUM-verifies the download). + # release host is likely blocked/throttled. Re-download the Electron + # binary via a public mirror, then retry. The mirror MUST drive + # electron's own downloader — `npm run pack` reads the pinned electronDist + # and never downloads, so a mirror passed only to pack is a no-op (#47266). if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then log_warn "Desktop build still failing — the Electron download from GitHub looks blocked." - log_warn "Retrying once via a public Electron mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR)..." + log_warn "Re-downloading Electron via a public mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR), then rebuilding..." log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)" - if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then - pack_ok=true + local have_dist=false + if _electron_dist_ok "$INSTALL_DIR"; then + have_dist=true + elif _restore_electron_dist "$INSTALL_DIR" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then + have_dist=true + fi + if [ "$have_dist" = true ]; then + if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then + pack_ok=true + fi + else + log_warn "Could not re-download Electron from the mirror (node_modules/electron/dist still missing)" fi fi diff --git a/tests/hermes_cli/test_gui_command.py b/tests/hermes_cli/test_gui_command.py index be84a30ee85..6bdd95a3551 100644 --- a/tests/hermes_cli/test_gui_command.py +++ b/tests/hermes_cli/test_gui_command.py @@ -498,9 +498,10 @@ def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch): assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)] -def test_gui_falls_back_to_mirror_when_purge_finds_nothing(tmp_path, monkeypatch, capsys): - """Purge clears nothing (not a cache problem) → fall back to an Electron - mirror once before failing, so a GitHub-blocked download self-heals.""" +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) _make_packaged_executable(root, monkeypatch, platform="linux") @@ -512,21 +513,59 @@ def test_gui_falls_back_to_mirror_when_purge_finds_nothing(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_macos_relaunchable_fixup"), \ - patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \ + 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 - mock_purge.assert_called_once() - # pack(fail) → purge(nothing) → pack via mirror(fail) = 2 subprocess.run calls + # 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 - # The retry runs the same build but with ELECTRON_MIRROR injected. + # 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_skips_pack_when_electron_redownload_unrecoverable(tmp_path, monkeypatch, capsys): + """When the Electron binary can't be fetched at all (mirror also blocked), + skip the pointless final pack — it would just re-throw the same missing + electronDist — and fail with a clear message instead.""" + root = _make_desktop_tree(tmp_path) + monkeypatch.setattr(cli_main, "PROJECT_ROOT", root) + _make_packaged_executable(root, monkeypatch, 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]) as mock_run, \ + pytest.raises(SystemExit) as exc: + cli_main.cmd_gui(_ns()) + + assert exc.value.code == 1 + # Only the initial pack ran; both retries were skipped because no binary + # could be produced. + assert mock_run.call_count == 1 + out = capsys.readouterr().out + assert "Could not re-download Electron from the mirror" in out + assert "Desktop GUI build failed" in 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).""" @@ -553,6 +592,108 @@ def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsy 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_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."""