Files
hermes-agent/tests/install/windows-installer-script-e2e.ps1
T
ethernet 239523414e test(install-e2e): installer-script+desktop is its own install and update method
The one-liner with its desktop stage opted in (--include-desktop /
-IncludeDesktop) is a real install kind, distinct on both sides:
on windows the stage builds Hermes.exe AND registers Start Menu /
Desktop shortcuts - a second path to a hand-launchable app - while
on linux/macos it builds into the checkout and registers no OS
entry point.

Declared on every OS and driven by both script drivers: the drivers
pass the flag through (hard failure if the ref predates it - the
tag-has-desktop gate already skips pre-desktop tags upstream) and
assert the built app exists under apps/desktop/release afterwards.
The run-workflow gates run +desktop pairs only on desktop-bearing
tags; app-update pairs from +desktop installs stay declared TODOs.
2026-08-12 04:01:16 -04:00

290 lines
13 KiB
PowerShell

# Prove a Windows user who installed OLD via the installer script (irm |
# iex) can reach HEAD.
#
# The install.ps1 sibling of tests/install/installer-script-e2e.sh, sharing
# its staging trick with the GUI driver (windows-desktop-gui-e2e.ps1):
# every git process is pointed at a local bare clone with
# url.<file://serve.git>.insteadOf rewrites for both canonical repo URLs in
# a driver-owned GIT_CONFIG_GLOBAL. The installer and updater run
# byte-for-byte against their real URLs and land on serve.git; `main`
# serves OLD during the install, then advances to HEAD for the update leg.
# (NOT GIT_CONFIG_COUNT/KEY_n/VALUE_n env config -- install.ps1 SETS those
# itself and would clobber ours.)
#
# install.ps1 itself is not downloaded: the install leg runs the copy
# shipped AT the OLD ref (what a user who installed then actually
# executed), and the installer-script update leg runs HEAD's copy (what
# the website serves at update time).
#
# Usage:
# powershell -File tests\install\windows-installer-script-e2e.ps1 `
# -UpdateMethod hermes-update -InstallRef v0.20.2
#
# -UpdateMethod hermes-update venv\Scripts\hermes.exe update
# installer-script re-run install.ps1 (HEAD's copy)
# -InstallRef what to install first; anything git resolves. auto =
# the newest release tag in the checkout.
#
# Requires a clean full-history checkout with release tags fetched.
# PowerShell 5.1-safe, pure ASCII (OEM codepages explode on fancy dashes).
#Requires -Version 5.1
param(
[ValidateSet("installer-script", "installer-script+desktop")]
[string]$InstallMethod = "installer-script",
[ValidateSet("hermes-update", "installer-script", "installer-script+desktop")]
[string]$UpdateMethod = "hermes-update",
[string]$InstallRef = "auto"
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
# Everything lives OUTSIDE the checkout; an untracked dir inside the repo
# would trip the dirty-tree guard below on the next run.
$WorkRoot = Join-Path $(if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { $env:TEMP }) "hermes-installer-script-e2e"
$LogDir = if ($env:HERMES_E2E_LOG_DIR) { $env:HERMES_E2E_LOG_DIR } else { Join-Path $WorkRoot "logs" }
$ServeRepo = Join-Path $WorkRoot "serve.git"
function Step([string]$Message) { Write-Host "`n=== $Message ===" }
function Ok([string]$Message) { Write-Host " OK $Message" }
function Fail([string]$Message) {
Write-Host "E2E ASSERTION FAILED: $Message" -ForegroundColor Red
exit 1
}
# Full transcript in the job log, collapsed (GitHub renders ::group:: as a
# fold). Win or lose -- a green install's log is how you diagnose the leg
# that fails next.
function Write-LogGroup([string]$Title, [string]$LogPath) {
Write-Host "::group::$Title"
if (Test-Path -LiteralPath $LogPath) { Get-Content -LiteralPath $LogPath | Write-Host }
Write-Host "::endgroup::"
}
function Invoke-Git {
param([string[]]$GitArgs)
$out = & git @GitArgs 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "git $($GitArgs -join ' ') exited $LASTEXITCODE`: $out"
}
return $out
}
if (Test-Path -LiteralPath $WorkRoot) { Remove-Item -Recurse -Force -LiteralPath $WorkRoot }
New-Item -ItemType Directory -Path $WorkRoot, $LogDir -Force | Out-Null
# --- stage: serve.git with main parked at OLD --------------------------------
Step "staging serve.git (main -> OLD)"
# Tracked changes only (-uno): untracked files cannot leak into a bare clone.
$dirty = Invoke-Git @("-C", $RepoRoot, "status", "--porcelain", "-uno")
if ($dirty) { Fail "checkout has uncommitted tracked changes; the staged clone must be a reviewable commit" }
if ($InstallRef -eq "auto") {
$tags = (Invoke-Git @("-C", $RepoRoot, "tag", "--list", "v[0-9]*", "--sort=-creatordate")) -split "`r?`n"
$InstallRef = $tags | Select-Object -First 1
if (-not $InstallRef) { Fail "no release tags in the checkout to use as OLD" }
}
$OldSha = (Invoke-Git @("-C", $RepoRoot, "rev-parse", "$InstallRef^{commit}")).Trim()
$HeadSha = (Invoke-Git @("-C", $RepoRoot, "rev-parse", "HEAD")).Trim()
if ($OldSha -eq $HeadSha) { Fail "OLD ($InstallRef) IS HEAD; no update would be available" }
Invoke-Git @("clone", "--bare", "--quiet", $RepoRoot, $ServeRepo) | Out-Null
Invoke-Git @("-C", $ServeRepo, "update-ref", "refs/heads/main", $OldSha) | Out-Null
Invoke-Git @("-C", $ServeRepo, "symbolic-ref", "HEAD", "refs/heads/main") | Out-Null
# The installer may pin a commit that is reachable but not at a ref tip.
Invoke-Git @("-C", $ServeRepo, "config", "uploadpack.allowAnySHA1InWant", "true") | Out-Null
Ok "serve.git main = $OldSha ($InstallRef), update target $HeadSha"
# --- the git URL redirect ------------------------------------------------------
$gitCfg = Join-Path $WorkRoot "gitconfig"
$serveUrl = "file:///" + ($ServeRepo -replace "\\", "/")
@"
[url "$serveUrl"]
insteadOf = $RepoUrlHttps
insteadOf = $RepoUrlSsh
"@ | Set-Content -LiteralPath $gitCfg -Encoding Ascii
$env:GIT_CONFIG_GLOBAL = $gitCfg
Ok "git URL redirect via GIT_CONFIG_GLOBAL=$gitCfg"
# Isolated install target: the runner may carry a preinstalled hermes.
# -HermesHome/-InstallDir are passed explicitly because the oldest sampled
# installers predate the HERMES_HOME env override.
$HermesHome = Join-Path $WorkRoot "hermes-home"
$InstallDir = Join-Path $HermesHome "hermes-agent"
New-Item -ItemType Directory -Path $HermesHome -Force | Out-Null
$env:HERMES_HOME = $HermesHome
# serve.git's file:// origin looks like a fork to the updater, whose "add
# the official repo as upstream?" prompt would hang a headless run. This
# marker is the product's own mechanism for suppressing it.
Set-Content -LiteralPath (Join-Path $HermesHome ".skip_upstream_prompt") -Value "" -Encoding Ascii
function Invoke-Installer {
param([string]$Ref, [string]$Label, [switch]$IncludeDesktop)
$script = Join-Path $WorkRoot "install-$Label.ps1"
(Invoke-Git @("-C", $RepoRoot, "show", "$Ref`:scripts/install.ps1")) -join "`n" |
Set-Content -LiteralPath $script -Encoding UTF8
# Flags must match the installer being run, not this checkout's: older
# releases reject parameters added later. -SkipSetup/-HermesHome/
# -InstallDir go back further than any tag we sample; -NonInteractive
# is probed from the ref's own script text.
$flags = @("-SkipSetup", "-HermesHome", $HermesHome, "-InstallDir", $InstallDir)
$text = Get-Content -LiteralPath $script -Raw
if ($text -match '\$NonInteractive') { $flags += "-NonInteractive" }
if ($IncludeDesktop) {
# The desktop stage is the point of this leg, so a ref without the
# parameter is a hard failure, not a silent downgrade to a plain
# install. (Pre-desktop releases are already skipped upstream by
# the tag-has-desktop gate; the parameter shipped with the app.)
if ($text -notmatch '\$IncludeDesktop') {
Fail "ref $Ref does not support -IncludeDesktop; this leg cannot mean what it claims"
}
$flags += "-IncludeDesktop"
}
$log = Join-Path $LogDir "install-$Label.log"
# Native stderr (git clone progress, pip notices) must not become
# terminating NativeCommandErrors under EAP=Stop; the exit code is the
# verdict here, not stderr chatter.
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
& powershell -NoProfile -ExecutionPolicy Bypass -File $script @flags *> $log
$installExit = $LASTEXITCODE
$ErrorActionPreference = $prevEap
Write-LogGroup "install.ps1 ($Label) transcript" $log
if ($installExit -ne 0) {
Fail "install.ps1 ($Label) exited $installExit; transcript above, log at $log"
}
}
function Assert-Checkout {
param([string]$ExpectedSha, [string]$Label)
$got = (Invoke-Git @("-C", $InstallDir, "rev-parse", "HEAD")).Trim()
if ($got -ne $ExpectedSha) { Fail "installed checkout is $got, expected $Label ($ExpectedSha)" }
Ok "checkout is $Label ($ExpectedSha)"
$hermes = Join-Path $InstallDir "venv\Scripts\hermes.exe"
if (-not (Test-Path -LiteralPath $hermes)) { Fail "no hermes console script at $hermes" }
$verLog = Join-Path $LogDir "version-$Label.log"
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
& $hermes --version *> $verLog
$verExit = $LASTEXITCODE
$ErrorActionPreference = $prevEap
if ($verExit -ne 0) {
Get-Content -LiteralPath $verLog | Write-Host
Fail "hermes --version failed after $Label; log in $verLog"
}
Ok "hermes --version works: $((Get-Content -LiteralPath $verLog -First 1))"
}
function Assert-DesktopArtifact {
param([string]$Label)
# After a +desktop install the built app must exist under the checkout;
# the installer also registers Start Menu / Desktop shortcuts, but the
# artifact is the ground truth a headless job can check.
$exe = Join-Path $InstallDir "apps\desktop\release\win-unpacked\Hermes.exe"
if (-not (Test-Path -LiteralPath $exe)) {
Fail "no desktop app at $exe after $Label (+desktop install)"
}
Ok "desktop app built by installer at ${Label}: $exe"
}
function Test-DesktopSmoke {
param([string]$Label)
# Prove the installed CLI can produce the desktop app: `hermes desktop
# --build-only` runs the full desktop pipeline (workspace install,
# renderer build, stamp write) and stops before the launch -- the same
# call `hermes update` itself makes. Probe the INSTALLED hermes for the
# flag rather than assuming this checkout's surface: sampled OLD
# releases may predate `hermes desktop` or --build-only entirely, and
# for them the phase skips, loudly.
$hermes = Join-Path $InstallDir "venv\Scripts\hermes.exe"
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
$helpText = & $hermes desktop --help 2>&1 | Out-String
$ErrorActionPreference = $prevEap
if ($helpText -notmatch '--build-only') {
Ok "hermes desktop --build-only not supported at $Label; skipping desktop smoke"
return
}
$log = Join-Path $LogDir "desktop-smoke-$Label.log"
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
Push-Location $InstallDir
try {
& $hermes desktop --build-only *> $log
$smokeExit = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
}
Write-LogGroup "hermes desktop --build-only ($Label) transcript" $log
if ($smokeExit -ne 0) {
Fail "hermes desktop --build-only ($Label) exited $smokeExit; transcript above"
}
Ok "hermes desktop --build-only works at $Label"
# TODO(launch): LAUNCH the built app and auto-close it. Mechanism when
# the pieces land: driver-side spawn interception (a sitecustomize.py
# on PYTHONPATH wraps subprocess.run under an env-var opt-in and
# captures the real argv/cwd/env at the spawn site) + Playwright
# _electron.launch on the captured spec; electronApp.close() is the
# auto-close.
}
# --- install OLD ------------------------------------------------------------------
Step "installing OLD ($InstallRef) via its own scripts/install.ps1 ($InstallMethod)"
if ($InstallMethod -eq "installer-script+desktop") {
Invoke-Installer $OldSha "old" -IncludeDesktop
Assert-Checkout $OldSha "OLD"
Assert-DesktopArtifact "OLD"
} else {
Invoke-Installer $OldSha "old"
Assert-Checkout $OldSha "OLD"
}
Test-DesktopSmoke "old"
# --- update OLD -> HEAD --------------------------------------------------------------
Step "advancing served main to HEAD"
Invoke-Git @("-C", $ServeRepo, "update-ref", "refs/heads/main", $HeadSha) | Out-Null
Ok "serve.git main = $HeadSha"
Step "updating via $UpdateMethod"
switch ($UpdateMethod) {
"hermes-update" {
$hermes = Join-Path $InstallDir "venv\Scripts\hermes.exe"
# `--yes` reaches the update subcommand only in later releases, and
# argparse rejects the whole invocation when it does not exist.
$updateArgs = @("update")
$prevEap = $ErrorActionPreference; $ErrorActionPreference = "Continue"
$helpText = & $hermes update --help 2>&1 | Out-String
if ($helpText -match '--yes') { $updateArgs += "--yes" }
$log = Join-Path $LogDir "update.log"
Push-Location $InstallDir
try {
& $hermes @updateArgs *> $log
$updateExit = $LASTEXITCODE
} finally {
Pop-Location
$ErrorActionPreference = $prevEap
}
Write-LogGroup "hermes update transcript" $log
if ($updateExit -ne 0) {
Fail "hermes update exited $updateExit; transcript above, log at $log"
}
}
"installer-script" {
# A user re-running the one-liner today gets the CURRENT script.
Invoke-Installer $HeadSha "head"
}
"installer-script+desktop" {
Invoke-Installer $HeadSha "head" -IncludeDesktop
Assert-DesktopArtifact "HEAD"
}
}
Assert-Checkout $HeadSha "HEAD"
Test-DesktopSmoke "head"
Step "PASS: $InstallRef -> HEAD via $UpdateMethod"