Files
hermes-agent/tests/scripts/install/test_install_ps1_native_calls.py
ethernet 1038f8479c fix(install): make both installers survive their real invocation paths
Review findings against the pm-clean installers, each reproduced first:

- install.sh: `curl | bash` aborted before main under `set -u` (empty
  BASH_SOURCE). The entry guard falls back to $0.
- install.sh: setup/gateway read stdin, which under `curl | bash` is the
  script itself. They open /dev/tty when a terminal can be opened, and
  otherwise skip with guidance.
- Both: any uv on PATH was trusted. uv 0.6.17 has no `python install
  --no-bin`. A PATH uv now has to run and be at least the pinned version,
  otherwise the pin is staged.
- install.sh: the staged uv went under ~/.hermes/tools even with a custom
  --hermes-home. It now goes to pm's store_root() default,
  $HERMES_HOME/tools.
- Both: when a stash failed, the script logged "overwritten below" and ran
  `reset --hard` anyway. Local work is now parked before checkout, and a
  stash failure stops the install.
- Both: reruns ignored an explicit HERMES_REPO_URL. It now repoints origin.
- Both: --commit had no ancestor guard. The pin must be on the installed
  branch.
- install.sh: the blobless fallback was `--depth 1 --single-branch`, so a
  non-tip --commit could not check out. It now keeps full history with
  blobs fetched on demand.
- Both: ported main's recovery for a commit-less .git (moved aside, #40998)
  and for an unmerged index (reset -q before the stash, #4735). Stashing
  before checkout makes both reachable.
- install.ps1: on Windows PowerShell 5.1, `native 2>$null` / `2>&1` under
  Stop turns stderr into a terminating NativeCommandError, verified on a
  Win11 host. Every native call now goes through Invoke-Native, which
  relaxes the preference only for that call.
- install.ps1: clone publishes from a staging dir, with retries and a
  blobless fallback, and refuses a non-empty destination (mirrors
  install.sh). UV_NO_CONFIG and `--no-registry` are restored. pwsh 7
  HttpRequestException falls back to the mirror, except TLS trust failures.
  tar resolves from System32. A literal CR/LF in the desktop failure
  message is removed.

Deletes six tests that regex-extracted main's legacy install.sh functions
(node, browsers, PATH block, lockfile churn; pm runs `npm ci` whenever a
lockfile exists). The two behaviours still relevant are covered by new
behavioural tests.
2026-09-23 16:28:00 -04:00

92 lines
4.0 KiB
Python

"""install.ps1 native calls survive Windows PowerShell 5.1, and reruns keep local work.
Windows PowerShell 5.1 turns a native command's redirected stderr into an
ErrorRecord that terminates a script running under
``$ErrorActionPreference = "Stop"``. These run the real installer under
``powershell.exe`` (5.1) -- dot-sourced for its helpers, or as the real
``-Stage repository -Json`` entry against local git repositories.
"""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
pytestmark = pytest.mark.platforms("windows")
INSTALLER = Path(__file__).resolve().parents[3] / "scripts" / "install.ps1"
def _powershell() -> str:
powershell = shutil.which("powershell")
assert powershell, "Windows PowerShell 5.1 is part of every supported Windows"
return powershell
def _dot_sourced(body: str) -> subprocess.CompletedProcess:
script = f'$ErrorActionPreference = "Stop"; . "{INSTALLER}"; {body}'
return subprocess.run([_powershell(), "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script],
capture_output=True, text=True, timeout=120)
def test_native_stderr_under_stop_leaves_the_exit_code_to_the_caller():
result = _dot_sourced('Invoke-Native { cmd /c "echo soft-failure 1>&2 & exit 3" 2>$null }; '
'"exit=$LASTEXITCODE preference=$ErrorActionPreference"')
assert result.returncode == 0, result.stdout + result.stderr
assert "exit=3 preference=Stop" in result.stdout
def test_path_uv_must_run_and_meet_the_pin(tmp_path):
for name, body in {"old": "@echo uv 0.6.17", "new": "@echo uv 99.0.0 (abc 2099-01-01)",
"broken": "@echo boom 1>&2 & exit /b 1"}.items():
(tmp_path / f"{name}.cmd").write_text(body + "\r\n")
checks = "; ".join(f'"{n}=$(Test-UvAtLeastPin \'{tmp_path / (n + ".cmd")}\')"' for n in ("old", "new", "broken"))
result = _dot_sourced(checks)
assert result.returncode == 0, result.stdout + result.stderr
assert result.stdout.split() == ["old=False", "new=True", "broken=False"]
def _git(repo: Path, *args: str) -> str:
return subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t", *args],
check=True, capture_output=True, text=True).stdout.strip()
def _stage(origin: Path, home: Path, *extra: str) -> tuple[subprocess.CompletedProcess, dict]:
env = dict(os.environ, HERMES_REPO_URL=str(origin), HERMES_HOME=str(home))
result = subprocess.run([_powershell(), "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(INSTALLER),
"-Stage", "repository", "-Json", *extra],
env=env, capture_output=True, text=True, timeout=180)
frames = [json.loads(line) for line in result.stdout.splitlines() if line.startswith("{")]
assert len(frames) == 1, result.stdout + result.stderr
return result, frames[0]
def test_rerun_parks_local_work_and_pins_only_branch_commits(tmp_path):
origin = tmp_path / "origin"
origin.mkdir()
_git(origin, "init", "-q", "-b", "main")
(origin / "README").write_text("one")
_git(origin, "add", "README")
_git(origin, "commit", "-qm", "one")
home = tmp_path / "home"
install = home / "hermes-agent"
assert _stage(origin, home)[1]["ok"] is True
(install / "README").write_text("local edit")
(origin / "README").write_text("two")
_git(origin, "commit", "-qam", "two")
_git(origin, "checkout", "-qb", "side")
(origin / "README").write_text("side")
_git(origin, "commit", "-qam", "side")
off_branch = _git(origin, "rev-parse", "HEAD")
_git(origin, "checkout", "-q", "main")
result, frame = _stage(origin, home)
assert frame["ok"] is True, result.stdout + result.stderr
assert (install / "README").read_text() == "two"
assert "local edit" in _git(install, "stash", "show", "-p", "stash@{0}")
result, frame = _stage(origin, home, "-Commit", off_branch)
assert frame["ok"] is False
assert "is not on branch main" in frame["reason"]