hermes-agent/tests/install/windows-desktop-e2e.ps1

340 lines
15 KiB
PowerShell

# ============================================================================
# Windows Desktop install + update E2E driver
# ============================================================================
# Proves, on a real Windows machine, that:
#
# 1. INSTALL - the commit-prior-to-HEAD ("BASE") installs from scratch
# through the real scripts/install.ps1 (uv, managed Python,
# Node, venv, packaged Desktop Hermes.exe).
# 2. UPDATE 1 - that BASE install updates to HEAD ("CURRENT") through the
# real Desktop GUI update path: scripts/desktop-update.ps1,
# the exact hand-off script the Update button spawns
# (see apps/desktop/electron/main.ts applyUpdates()).
# 3. UPDATE 2 - the now-CURRENT install updates once more to a synthetic
# "NEXT" commit staged on top of HEAD, proving the *outgoing*
# update path of the commit under test is not broken either.
# (before -> current proves we can be updated TO;
# current -> next proves we can update FROM.)
#
# HOW THE STAGING WORKS (no MITM proxy, no network fakery):
# We bare-clone the checkout into <workroot>\serve.git and point every git
# process at it with url.<file-url>.insteadOf rewrites for the two
# canonical repo URLs, injected via GIT_CONFIG_{COUNT,KEY_n,VALUE_n}
# environment variables. install.ps1's `git clone` and `hermes update`'s
# `git fetch origin` therefore transparently hit OUR bare repo, whose
# `main` ref we advance between legs: BASE -> CURRENT -> NEXT. The
# installer and updater code paths run unmodified, byte-for-byte as in
# production. Everything else (uv, PyPI, npm, PortableGit download) uses
# the real network, same as a user install.
#
# NEXT is a same-tree child of CURRENT (git commit-tree), so it exists
# only in serve.git and can never leak a content change; leg 3 verifies
# by commit SHA.
#
# WHY NOT AutoHotkey / pixel-driving the installer window (the prior
# attempt, PR #68183): image-search button clicking is resolution- and
# theme-fragile and never stabilized. The GUI Update button's entire effect
# is spawning desktop-update.ps1 with documented flags (its "CONTRACT"
# header); driving that contract directly tests the same production code
# deterministically. -NoUi and the DesktopPid wait gate exist in that script
# precisely for tests.
#
# USAGE (local Windows box or CI):
# powershell -File tests\install\windows-desktop-e2e.ps1 # all
# powershell -File tests\install\windows-desktop-e2e.ps1 -Phase stage
# ... -Phase install / update-to-current / update-to-next
# Phases share state via <workroot>\shas.json, so CI can run them as
# separate steps for readable logs.
#
# The workroot defaults to $env:HERMES_E2E_WORKROOT or %TEMP%. Pass -Keep
# to leave everything on disk for inspection.
# ============================================================================
param(
[ValidateSet("stage", "install", "update-to-current", "update-to-next", "all")]
[string]$Phase = "all",
# Repo checkout whose HEAD is the commit under test.
[string]$RepoRoot = "",
# Where the bare serve repo + isolated HERMES_HOME live.
[string]$WorkRoot = $(if ($env:HERMES_E2E_WORKROOT) { $env:HERMES_E2E_WORKROOT } else { Join-Path $env:TEMP "hermes-desktop-e2e" }),
# Keep the workroot after a successful `-Phase all` run.
[switch]$Keep
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
}
$ServeRepo = Join-Path $WorkRoot "serve.git"
$HermesHome = Join-Path $WorkRoot "hermes-home"
$InstallDir = Join-Path $HermesHome "hermes-agent"
$StatePath = Join-Path $WorkRoot "shas.json"
# The two URL spellings install.ps1 clones from and `hermes update` fetches
# from. insteadOf is prefix-based; we register the exact full forms only, so
# nothing else can accidentally rewrite to a path + ".git" suffix.
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
$RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
function Write-Step([string]$Message) {
Write-Host ""
Write-Host ("=" * 74)
Write-Host "== $Message"
Write-Host ("=" * 74)
}
function Assert-True([bool]$Condition, [string]$Message) {
if (-not $Condition) {
throw "E2E ASSERTION FAILED: $Message"
}
Write-Host " [ok] $Message"
}
function Invoke-Git([string[]]$GitArgs, [string]$WorkDir = $null) {
$prev = if ($WorkDir) { Get-Location } else { $null }
# PS 5.1 trap: under $ErrorActionPreference = "Stop", a native command
# that writes ANYTHING to stderr while merged via 2>&1 throws a
# NativeCommandError even when it exits 0 (git loves stderr for
# progress/notices). Relax EAP around the native call only; exit-code
# checking below is the real error gate.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
if ($WorkDir) { Set-Location $WorkDir }
$output = & git @GitArgs 2>&1
if ($LASTEXITCODE -ne 0) {
throw "git $($GitArgs -join ' ') failed (exit $LASTEXITCODE): $output"
}
return ($output | Out-String).Trim()
} finally {
$ErrorActionPreference = $prevEap
if ($prev) { Set-Location $prev }
}
}
function Set-GitRedirect {
# Route the canonical repo URLs to the local bare repo for THIS process
# and every child (install.ps1's git, hermes update's git).
#
# MECHANISM: a driver-owned global gitconfig selected via
# GIT_CONFIG_GLOBAL. Do NOT use GIT_CONFIG_COUNT/KEY_n/VALUE_n env
# config here -- install.ps1 SETS those itself (GIT_CONFIG_COUNT=1,
# windows.appendAtomically), silently clobbering any redirect we put
# there. That exact clobber made the first CI run clone real GitHub
# main instead of the staged BASE (caught by the HEAD-at-BASE assert).
# install.ps1's own `git config --global` writes simply land in our
# file, so its compat settings still apply. Nothing leaks onto the
# machine: the file lives in the workroot and dies with it.
$fileUrl = "file:///" + ($ServeRepo -replace "\\", "/")
$gitCfg = Join-Path $WorkRoot "e2e-gitconfig"
if (-not (Test-Path -LiteralPath $WorkRoot)) {
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
}
@"
[url "$fileUrl"]
insteadOf = $RepoUrlHttps
insteadOf = $RepoUrlSsh
"@ | Set-Content -LiteralPath $gitCfg -Encoding ASCII
$env:GIT_CONFIG_GLOBAL = $gitCfg
Write-Host " git URL redirect via GIT_CONFIG_GLOBAL=$gitCfg"
Write-Host " $RepoUrlHttps -> $fileUrl"
}
function Read-State {
if (-not (Test-Path -LiteralPath $StatePath)) {
throw "State file not found: $StatePath -- run '-Phase stage' first."
}
return Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json
}
function Get-InstalledHead {
return Invoke-Git @("-C", $InstallDir, "rev-parse", "HEAD")
}
function Get-DesktopExe {
$candidates = @(
(Join-Path $InstallDir "apps\desktop\release\win-unpacked\Hermes.exe"),
(Join-Path $InstallDir "apps\desktop\release\win-arm64-unpacked\Hermes.exe")
)
foreach ($c in $candidates) {
if (Test-Path -LiteralPath $c) { return $c }
}
return $null
}
function Test-HermesRuns([string]$Label) {
$hermesExe = Join-Path $InstallDir "venv\Scripts\hermes.exe"
Assert-True (Test-Path -LiteralPath $hermesExe) "$Label -- venv\Scripts\hermes.exe exists"
& $hermesExe --version 2>&1 | ForEach-Object { Write-Host " hermes --version| $_" }
Assert-True ($LASTEXITCODE -eq 0) "$Label -- hermes --version exits 0"
}
# ----------------------------------------------------------------------------
# Phase: stage
# ----------------------------------------------------------------------------
function Invoke-PhaseStage {
Write-Step "STAGE: bare serve repo + BASE/CURRENT/NEXT refs"
if (Test-Path -LiteralPath $WorkRoot) {
Remove-Item -LiteralPath $WorkRoot -Recurse -Force
}
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
# The purge above deleted the redirect gitconfig; re-arm it so the
# bare-clone below (and everything after) sees the redirect file.
Set-GitRedirect
$current = Invoke-Git @("-C", $RepoRoot, "rev-parse", "HEAD")
$base = Invoke-Git @("-C", $RepoRoot, "rev-parse", "HEAD~1")
Write-Host " CURRENT (commit under test): $current"
Write-Host " BASE (its parent): $base"
# Bare-clone the checkout: this is the repo the installer and updater
# will actually talk to. Local-path clone hardlinks objects, so it's
# fast even for full history.
Invoke-Git @("clone", "--bare", "--quiet", $RepoRoot, $ServeRepo) | Out-Null
# Synthesize NEXT inside the bare repo: a same-tree child of CURRENT.
# It exists nowhere else, which is the point -- leg 3 proves the commit
# under test can update to a future main it has never seen.
$env:GIT_AUTHOR_NAME = "Hermes E2E"; $env:GIT_AUTHOR_EMAIL = "e2e@nousresearch.com"
$env:GIT_COMMITTER_NAME = "Hermes E2E"; $env:GIT_COMMITTER_EMAIL = "e2e@nousresearch.com"
$next = Invoke-Git @("-C", $ServeRepo, "commit-tree", "$current^{tree}", "-p", $current, "-m", "e2e: synthetic next commit (same tree as CURRENT)")
Write-Host " NEXT (synthetic): $next"
# Serve BASE as `main` first; update legs advance this ref.
Invoke-Git @("-C", $ServeRepo, "update-ref", "refs/heads/main", $base) | Out-Null
Invoke-Git @("-C", $ServeRepo, "symbolic-ref", "HEAD", "refs/heads/main") | Out-Null
@{ base = $base; current = $current; next = $next } |
ConvertTo-Json | Set-Content -LiteralPath $StatePath -Encoding UTF8
Write-Host " state written: $StatePath"
}
# ----------------------------------------------------------------------------
# Phase: install (BASE, via BASE's own install.ps1)
# ----------------------------------------------------------------------------
function Invoke-PhaseInstall {
$state = Read-State
Write-Step "INSTALL: BASE ($($state.base)) via its own install.ps1 -IncludeDesktop"
# The honest test runs the installer *as it existed at BASE* -- a user
# installing yesterday used yesterday's script.
$baseInstaller = Join-Path $WorkRoot "install-base.ps1"
& git -C $ServeRepo show "$($state.base):scripts/install.ps1" |
Set-Content -LiteralPath $baseInstaller -Encoding UTF8
if ($LASTEXITCODE -ne 0) { throw "could not extract scripts/install.ps1 at BASE" }
$env:HERMES_HOME = $HermesHome
New-Item -ItemType Directory -Path $HermesHome -Force | Out-Null
# Windows PowerShell 5.1 on purpose: it is what the production
# `irm | iex` one-liner and the desktop bootstrap both run under.
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $baseInstaller `
-NonInteractive -SkipSetup -IncludeDesktop `
-HermesHome $HermesHome -InstallDir $InstallDir
Assert-True ($LASTEXITCODE -eq 0) "install.ps1 (BASE) exited 0"
Assert-True ((Get-InstalledHead) -eq $state.base) "installed checkout is at BASE"
Test-HermesRuns "post-install"
$desktopExe = Get-DesktopExe
Assert-True ($null -ne $desktopExe) "packaged Desktop Hermes.exe exists ($desktopExe)"
}
# ----------------------------------------------------------------------------
# Update legs (shared): the real Desktop GUI update path
# ----------------------------------------------------------------------------
function Invoke-DesktopUpdateLeg([string]$TargetSha, [string]$LegName) {
Write-Step "UPDATE ($LegName): advance served main -> $TargetSha, run desktop-update.ps1"
$env:HERMES_HOME = $HermesHome
Invoke-Git @("-C", $ServeRepo, "update-ref", "refs/heads/main", $TargetSha) | Out-Null
# The hand-off script from the INSTALLED checkout drives the update --
# exactly the production contract: each `hermes update` refreshes the
# script that drives the NEXT update.
$handoff = Join-Path $InstallDir "scripts\desktop-update.ps1"
Assert-True (Test-Path -LiteralPath $handoff) "installed checkout ships scripts/desktop-update.ps1"
# Stand in for the exiting Electron main process: desktop-update.ps1
# FAIL-CLOSED waits for this pid to exit before touching the install.
# A short-lived real process exercises that gate.
$dummy = Start-Process -FilePath "powershell.exe" `
-ArgumentList "-NoProfile", "-Command", "Start-Sleep -Seconds 3" `
-WindowStyle Hidden -PassThru
$resultPath = Join-Path $HermesHome ".hermes-update-result.json"
$markerPath = Join-Path $HermesHome ".hermes-update-in-progress"
Remove-Item -LiteralPath $resultPath -Force -ErrorAction SilentlyContinue
# -NoUi: headless (no WinForms in CI). No -RelaunchExe: contract says
# omit = no relaunch, so no orphaned Electron process on the runner.
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $handoff `
-InstallRoot $InstallDir -Branch main -DesktopPid $dummy.Id -NoUi
$handoffExit = $LASTEXITCODE
# Surface the hand-off log before asserting, so failures are debuggable
# straight from the CI step output.
$logPath = Join-Path $HermesHome "logs\desktop-update-handoff.log"
if (Test-Path -LiteralPath $logPath) {
Write-Host " --- desktop-update-handoff.log (tail) ---"
Get-Content -LiteralPath $logPath -Tail 40 | ForEach-Object { Write-Host " | $_" }
}
Assert-True ($handoffExit -eq 0) "$LegName -- desktop-update.ps1 exited 0"
Assert-True (Test-Path -LiteralPath $resultPath) "$LegName -- update result JSON written"
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
Assert-True ([bool]$result.ok) "$LegName -- result JSON reports ok=true ('$($result.message)')"
Assert-True (-not (Test-Path -LiteralPath $markerPath)) "$LegName -- update marker cleaned up"
Assert-True ((Get-InstalledHead) -eq $TargetSha) "$LegName -- checkout landed on target commit"
Test-HermesRuns $LegName
Assert-True ($null -ne (Get-DesktopExe)) "$LegName -- Desktop Hermes.exe still present after update"
}
function Invoke-PhaseUpdateToCurrent {
$state = Read-State
Invoke-DesktopUpdateLeg $state.current "BASE -> CURRENT"
}
function Invoke-PhaseUpdateToNext {
$state = Read-State
Invoke-DesktopUpdateLeg $state.next "CURRENT -> NEXT"
}
# ----------------------------------------------------------------------------
# Dispatch
# ----------------------------------------------------------------------------
Write-Host "Windows Desktop E2E driver"
Write-Host " phase: $Phase"
Write-Host " repo: $RepoRoot"
Write-Host " workroot: $WorkRoot"
Set-GitRedirect
switch ($Phase) {
"stage" { Invoke-PhaseStage }
"install" { Invoke-PhaseInstall }
"update-to-current" { Invoke-PhaseUpdateToCurrent }
"update-to-next" { Invoke-PhaseUpdateToNext }
"all" {
Invoke-PhaseStage
Invoke-PhaseInstall
Invoke-PhaseUpdateToCurrent
Invoke-PhaseUpdateToNext
if (-not $Keep) {
Write-Step "CLEANUP"
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
Write-Host ""
Write-Host "Phase '$Phase' completed successfully."