Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
# Unit tests for install.ps1's Git Bash compatibility and Mandatory-ASLR
|
||||
# guidance helpers. The installer itself is never executed: functions are
|
||||
# extracted through the PowerShell AST to avoid downloads, PATH changes, or
|
||||
# user-environment writes.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
$installScript = Join-Path $repoRoot "scripts\install.ps1"
|
||||
|
||||
$failures = 0
|
||||
function Assert-Equal {
|
||||
param($Expected, $Actual, [string]$Label)
|
||||
if ($Expected -ne $Actual) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
Write-Host " expected: $Expected"
|
||||
Write-Host " actual: $Actual"
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
function Assert-True {
|
||||
param($Condition, [string]$Label)
|
||||
if (-not $Condition) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
$tokens = $null
|
||||
$parseErrors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
$installScript, [ref]$tokens, [ref]$parseErrors
|
||||
)
|
||||
if ($parseErrors.Count -gt 0) {
|
||||
throw "install.ps1 has parse errors: $($parseErrors -join '; ')"
|
||||
}
|
||||
|
||||
foreach ($name in @(
|
||||
"Test-GitBashCompatibility",
|
||||
"Test-MandatoryAslrEnabled",
|
||||
"Get-GitRootFromBashPath",
|
||||
"New-GitBashAslrFailureReason",
|
||||
"Stage-Git"
|
||||
)) {
|
||||
$fnAst = $ast.FindAll(
|
||||
{
|
||||
param($node)
|
||||
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
|
||||
$node.Name -eq $name
|
||||
}, $true
|
||||
) | Select-Object -First 1
|
||||
if (-not $fnAst) { throw "$name not found in install.ps1" }
|
||||
. ([scriptblock]::Create($fnAst.Extent.Text))
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- Git root resolution --"
|
||||
Assert-Equal "C:\Program Files\Git" `
|
||||
(Get-GitRootFromBashPath "C:\Program Files\Git\bin\bash.exe") `
|
||||
"PortableGit/full Git bin layout"
|
||||
Assert-Equal "C:\Program Files\Git" `
|
||||
(Get-GitRootFromBashPath "C:\Program Files\Git\usr\bin\bash.exe") `
|
||||
"usr/bin layout"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- Mandatory ASLR detection --"
|
||||
function Get-ProcessMitigation {
|
||||
param([switch]$System)
|
||||
[pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "ON" } }
|
||||
}
|
||||
Assert-Equal $true (Test-MandatoryAslrEnabled) "ForceRelocateImages ON is detected"
|
||||
function Get-ProcessMitigation {
|
||||
param([switch]$System)
|
||||
[pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "NOTSET" } }
|
||||
}
|
||||
Assert-Equal $false (Test-MandatoryAslrEnabled) "ForceRelocateImages NOTSET is not diagnosed"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- Actionable remediation --"
|
||||
$reason = New-GitBashAslrFailureReason "C:\Program Files\Git\bin\bash.exe"
|
||||
Assert-True ($reason -match "Mandatory ASLR") "reason identifies Mandatory ASLR"
|
||||
Assert-True ($reason -match "Reinstalling Git will not change") "reason rejects ineffective reinstall"
|
||||
Assert-True ($reason -match [regex]::Escape("C:\Program Files\Git")) "reason uses selected Git root"
|
||||
Assert-True ($reason -match "Set-ProcessMitigation") "reason includes targeted mitigation command"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- External-program probe --"
|
||||
$gitCommand = Get-Command git -ErrorAction SilentlyContinue
|
||||
$gitBash = $null
|
||||
if ($gitCommand -and $gitCommand.Source) {
|
||||
$gitRoot = Split-Path (Split-Path $gitCommand.Source -Parent) -Parent
|
||||
foreach ($candidate in @("$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\bash.exe")) {
|
||||
if (Test-Path -LiteralPath $candidate) { $gitBash = $candidate; break }
|
||||
}
|
||||
}
|
||||
if ($gitBash) {
|
||||
Assert-Equal $true (Test-GitBashCompatibility $gitBash) `
|
||||
"installed Git Bash launches external MSYS programs"
|
||||
} else {
|
||||
Write-Host "SKIP: no Git Bash found next to git.exe" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- Stage failure propagation --"
|
||||
function Install-Git { return $false }
|
||||
$script:GitInstallFailureReason = "specific Git Bash failure"
|
||||
$stageError = $null
|
||||
try { Stage-Git } catch { $stageError = $_.Exception.Message }
|
||||
Assert-Equal "specific Git Bash failure" $stageError "Git stage preserves actionable reason"
|
||||
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) {
|
||||
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "All Git Bash compatibility tests passed." -ForegroundColor Green
|
||||
exit 0
|
||||
@@ -0,0 +1,323 @@
|
||||
# Tests for install.ps1's 8.3 short-path normalization.
|
||||
#
|
||||
# Run from a PowerShell prompt:
|
||||
#
|
||||
# pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-longpath.ps1
|
||||
#
|
||||
# Background: when the Windows profile folder's name contains a space
|
||||
# ("First Last"), a dot ("Stone.ZEN8"), or an accented character, Windows can
|
||||
# expose %TEMP%, %LOCALAPPDATA% and friends as an 8.3 alias
|
||||
# (C:\Users\FIRST~1.LAS\...). PowerShell's FileSystem provider chokes on the
|
||||
# aliased component once it reaches a provider cmdlet (Tee-Object -FilePath),
|
||||
# aborting the Node/Electron stages and the desktop post-build probe.
|
||||
# install.ps1 expands those paths up front; this asserts that contract.
|
||||
#
|
||||
# HOW THIS RUNS THE CODE: by executing install.ps1 as a real subprocess with a
|
||||
# crafted environment and reading what it reports back. `-ProtocolVersion` is a
|
||||
# side-effect-free early exit that sits BELOW the normalization block, so the
|
||||
# whole block -- including the script-level Add-Type the kernel32 resolver
|
||||
# needs -- executes exactly as it does during an install. Nothing here parses
|
||||
# install.ps1's source (AGENTS.md bans source-reading tests: they pass on
|
||||
# broken code and fail on correct refactors).
|
||||
#
|
||||
# HERMETIC ENVIRONMENT: every case sets all five profile variables explicitly.
|
||||
# GitHub's own Windows runners hand down a genuinely 8.3-aliased TEMP/TMP
|
||||
# (C:\Users\RUNNER~1\AppData\Local\Temp), so an inherited variable is a live
|
||||
# instance of the very bug under test and would contaminate any case that
|
||||
# didn't override it.
|
||||
#
|
||||
# Portability: resolver 3 (profile-root substitution) is pure path arithmetic,
|
||||
# so the substitution assertions run everywhere, including non-Windows CI. The
|
||||
# kernel32 and COM resolvers only have anything to expand on a real Windows
|
||||
# volume; on other hosts they no-op and fall through, which is itself the
|
||||
# graceful-degradation contract asserted below.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
$installScript = Join-Path $repoRoot "scripts/install.ps1"
|
||||
|
||||
if (-not (Test-Path $installScript)) {
|
||||
throw "Could not locate install.ps1 at $installScript"
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
$script:lastRaw = ''
|
||||
|
||||
function Assert-Equal {
|
||||
param($Expected, $Actual, [Parameter(Mandatory = $true)][string]$Label)
|
||||
if ($Expected -ne $Actual) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
Write-Host " expected: $Expected"
|
||||
Write-Host " actual: $Actual"
|
||||
if ($script:lastRaw) {
|
||||
# The installer's own account of what it did, plus the environment
|
||||
# it was handed. Without both, a failure on a host you cannot reach
|
||||
# is pure guesswork.
|
||||
Write-Host " installer reported: $script:lastRaw"
|
||||
Write-Host " environment sent: $script:lastEnv"
|
||||
}
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# --- Harness ---------------------------------------------------------------
|
||||
# The real profile root the installer will substitute in, derived the same way
|
||||
# install.ps1 derives it so these assertions hold on any host and any account.
|
||||
$profileDir = [Environment]::GetFolderPath('UserProfile')
|
||||
$usersDir = Split-Path -Parent $profileDir
|
||||
$sep = [System.IO.Path]::DirectorySeparatorChar
|
||||
|
||||
# Starting guess for the baseline environment; replaced by the probe below with
|
||||
# whatever root the installer itself resolves.
|
||||
$script:baseRoot = $profileDir
|
||||
|
||||
# A profile alias that cannot resolve: no such folder exists, so kernel32 and
|
||||
# COM both fail and only the profile-root substitution can handle it.
|
||||
$shortProfile = Join-Path $usersDir 'FIRST~1.LAS'
|
||||
|
||||
function Join-Parts {
|
||||
# Join path segments with the platform separator. Literal forward slashes
|
||||
# inside a path would make Split-Path's behavior host-dependent, which is
|
||||
# noise this suite doesn't need.
|
||||
param([string[]]$Parts)
|
||||
return ($Parts -join $sep)
|
||||
}
|
||||
|
||||
# Ask install.ps1 what paths it resolves under a given environment.
|
||||
#
|
||||
# -ShowResolvedPaths prints a JSON object on STDOUT and exits without touching
|
||||
# anything, so the whole normalization block -- including the script-level
|
||||
# Add-Type the kernel32 resolver needs -- has already run by the time it is
|
||||
# printed. Stdout, deliberately: three separate stderr capture mechanisms
|
||||
# (ProcessStartInfo.RedirectStandardError, `2>$file`, and a merged `2>&1`
|
||||
# pipeline) were each verified to come back EMPTY from the installer on a
|
||||
# windows-latest runner while stdout arrived intact. The installer's human
|
||||
# diagnostics still go to stderr; the machine-readable contract is on stdout,
|
||||
# which is the only stream that survives everywhere.
|
||||
#
|
||||
# Environment overrides are applied to this process and restored afterwards,
|
||||
# since that is what the child inherits.
|
||||
function Invoke-Normalization {
|
||||
param(
|
||||
[hashtable]$Environment = @{},
|
||||
[string[]]$ExtraArgs = @()
|
||||
)
|
||||
|
||||
# Start from a long, self-consistent profile so nothing is inherited;
|
||||
# callers override only the variables their case is about. $script:baseRoot
|
||||
# is the test's best guess until the probe below replaces it with the root
|
||||
# the installer actually resolves.
|
||||
$root = $script:baseRoot
|
||||
$env0 = @{
|
||||
TEMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp'))
|
||||
TMP = (Join-Parts @($root, 'AppData', 'Local', 'Temp'))
|
||||
LOCALAPPDATA = (Join-Parts @($root, 'AppData', 'Local'))
|
||||
APPDATA = (Join-Parts @($root, 'AppData', 'Roaming'))
|
||||
USERPROFILE = $root
|
||||
HERMES_HOME = ''
|
||||
}
|
||||
foreach ($key in $Environment.Keys) { $env0[$key] = $Environment[$key] }
|
||||
|
||||
$psExe = (Get-Process -Id $PID).Path
|
||||
$outFile = [System.IO.Path]::GetTempFileName()
|
||||
$errFile = [System.IO.Path]::GetTempFileName()
|
||||
$saved = @{}
|
||||
foreach ($key in $env0.Keys) { $saved[$key] = [Environment]::GetEnvironmentVariable($key) }
|
||||
|
||||
try {
|
||||
foreach ($key in $env0.Keys) { Set-Item -Path "Env:$key" -Value $env0[$key] }
|
||||
$callArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $installScript) + $ExtraArgs + @('-ShowResolvedPaths')
|
||||
# The call operator, not Start-Process: on Windows Start-Process does
|
||||
# not hand the parent's modified environment block to the child, so the
|
||||
# installer saw the runner's real TEMP instead of the aliased one this
|
||||
# case sets, and every rewrite assertion came back "not rewritten".
|
||||
# `&` inherits the environment on every host.
|
||||
#
|
||||
# stderr is merged into the same file rather than redirected separately:
|
||||
# Windows PowerShell 5.1 wraps ANY stderr from a native command in a
|
||||
# NativeCommandError record, and a bare `2>$file` still emits that
|
||||
# record into this script's error stream, which fails the 5.1 lane even
|
||||
# under 'Continue'. Merging with 2>&1 keeps the bytes and produces no
|
||||
# error record. The installer's stdout here is a single JSON object and
|
||||
# its diagnostics are all `[hermes] `-prefixed, so the two separate
|
||||
# cleanly on the way back out.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$global:LASTEXITCODE = 0
|
||||
try {
|
||||
& $psExe @callArgs *> $outFile
|
||||
} finally {
|
||||
$ErrorActionPreference = $prevEAP
|
||||
}
|
||||
$exitCode = $LASTEXITCODE
|
||||
$raw = @(Get-Content -LiteralPath $outFile -ErrorAction SilentlyContinue)
|
||||
$stderr = ($raw | Where-Object { $_ -like '`[hermes`]*' }) -join "`n"
|
||||
$stdout = ($raw | Where-Object { $_ -notlike '`[hermes`]*' }) -join "`n"
|
||||
} finally {
|
||||
foreach ($key in $saved.Keys) {
|
||||
if ($null -eq $saved[$key]) {
|
||||
Remove-Item -LiteralPath "Env:$key" -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Set-Item -Path "Env:$key" -Value $saved[$key]
|
||||
}
|
||||
}
|
||||
Remove-Item -LiteralPath $outFile, $errFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($null -eq $stdout) { $stdout = '' }
|
||||
$stdout = $stdout.Trim()
|
||||
$script:lastRaw = if ($stdout) { $stdout } else { '(child produced no stdout)' }
|
||||
$script:lastEnv = ($env0.Keys | Sort-Object | ForEach-Object { "$_=$($env0[$_])" }) -join '; '
|
||||
|
||||
$paths = $null
|
||||
if ($stdout) {
|
||||
try { $paths = $stdout | ConvertFrom-Json } catch { $paths = $null }
|
||||
}
|
||||
|
||||
# normalized is an object keyed by variable name; flatten to a hashtable so
|
||||
# callers can ask "was TEMP rewritten, and to what".
|
||||
$rewrites = @{}
|
||||
if ($paths -and $paths.normalized) {
|
||||
foreach ($prop in $paths.normalized.PSObject.Properties) {
|
||||
$rewrites[$prop.Name] = "$($prop.Value)"
|
||||
}
|
||||
}
|
||||
|
||||
return @{
|
||||
ExitCode = $exitCode
|
||||
Stdout = $stdout
|
||||
Rewrites = $rewrites
|
||||
InstallDir = $(if ($paths) { $paths.install_dir } else { $null })
|
||||
HermesHome = $(if ($paths) { $paths.hermes_home } else { $null })
|
||||
LongRoot = $(if ($paths) { $paths.long_profile_root } else { $null })
|
||||
}
|
||||
}
|
||||
|
||||
function Get-Rewrite {
|
||||
# '' rather than $null for an untouched variable, so a failure prints
|
||||
# something legible instead of a blank.
|
||||
param($Result, [string]$Name)
|
||||
if ($Result.Rewrites.ContainsKey($Name)) { return $Result.Rewrites[$Name] }
|
||||
return '<not rewritten>'
|
||||
}
|
||||
|
||||
# Ask the installer once, up front, which long root it resolves on this host,
|
||||
# and assert every expectation against that. Deriving it independently in the
|
||||
# test would only prove the two derivations agree, not that the fix works --
|
||||
# and on GitHub's Windows runners they don't agree, because the runner hands
|
||||
# down a genuinely 8.3-aliased profile.
|
||||
$probe = Invoke-Normalization @{ USERPROFILE = $shortProfile }
|
||||
$longRoot = $probe.LongRoot
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- the installer resolves a long profile root --"
|
||||
if ([string]::IsNullOrEmpty($longRoot)) {
|
||||
# Nothing below can mean anything without this, so show the child's whole
|
||||
# output rather than leaving a bare assertion failure on an unreachable host.
|
||||
Write-Host "FAIL: a long profile root is found" -ForegroundColor Red
|
||||
Write-Host " probe exit code: $($probe.ExitCode)"
|
||||
Write-Host " probe stdout: $($probe.Stdout)"
|
||||
Write-Host " probe env: $script:lastEnv"
|
||||
Write-Host " probe stdout (raw):"
|
||||
foreach ($line in ($script:lastRaw -split "`r?`n")) {
|
||||
if ($line.Trim()) { Write-Host " $line" }
|
||||
}
|
||||
Write-Host "FAILED: cannot continue without a long profile root" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "OK: a long profile root is found ($longRoot)" -ForegroundColor Green
|
||||
Assert-Equal -Expected $false -Actual ($longRoot -match '~\d') -Label "the resolved root carries no 8.3 alias"
|
||||
# Every subsequent case's baseline is now the installer's own root, so a
|
||||
# "nothing to expand" case really has nothing to expand even on a runner whose
|
||||
# inherited profile is itself aliased.
|
||||
$script:baseRoot = $longRoot
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- normalization is a no-op for ordinary paths --"
|
||||
|
||||
# A profile name with a space is NOT itself a short path; nothing to expand.
|
||||
$result = Invoke-Normalization
|
||||
Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "long paths: install.ps1 still reaches its early exit"
|
||||
Assert-Equal -Expected 0 -Actual $result.Rewrites.Count -Label "long paths: nothing rewritten"
|
||||
Assert-Equal -Expected $false -Actual ($result.InstallDir -match '~\d') -Label "long paths: InstallDir passes through clean"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- an unresolvable profile alias is rebuilt on the long profile root --"
|
||||
|
||||
# The reported failure: TEMP under an 8.3 profile alias that no resolver can
|
||||
# expand (8dot3 disabled, or a stale alias). GH #52842, GH #57526.
|
||||
$shortTemp = Join-Parts @($shortProfile, 'AppData', 'Local', 'Temp')
|
||||
$expectedTemp = Join-Parts @($profileDir, 'AppData', 'Local', 'Temp')
|
||||
|
||||
$result = Invoke-Normalization @{ TEMP = $shortTemp; TMP = $shortTemp }
|
||||
Assert-Equal -Expected 0 -Actual $result.ExitCode -Label "short TEMP: install.ps1 still reaches its early exit"
|
||||
$expectedTemp = "$longRoot${sep}AppData${sep}Local${sep}Temp"
|
||||
Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP is rebuilt on the long profile root"
|
||||
Assert-Equal -Expected $expectedTemp -Actual (Get-Rewrite $result 'TMP') -Label "short TMP is rebuilt on the long profile root"
|
||||
Assert-Equal -Expected 2 -Actual $result.Rewrites.Count -Label "short TEMP: only the aliased variables are touched"
|
||||
|
||||
# The profile root itself, with no tail to reattach. USERPROFILE is also where
|
||||
# the installer looks first for a long root, so this exercises the fallback to
|
||||
# HOMEDRIVE/HOMEPATH and %USERNAME%.
|
||||
$result = Invoke-Normalization @{ USERPROFILE = $shortProfile }
|
||||
Assert-Equal -Expected $longRoot -Actual (Get-Rewrite $result 'USERPROFILE') -Label "bare short profile root expands to the long root"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- every profile-rooted variable is covered, not just TEMP --"
|
||||
|
||||
# The desktop stage derives InstallDir from %LOCALAPPDATA%; a short root there
|
||||
# fails the post-build probe after the build already succeeded (GH #52842).
|
||||
$result = Invoke-Normalization @{
|
||||
TEMP = $shortTemp
|
||||
TMP = $shortTemp
|
||||
LOCALAPPDATA = (Join-Parts @($shortProfile, 'AppData', 'Local'))
|
||||
APPDATA = (Join-Parts @($shortProfile, 'AppData', 'Roaming'))
|
||||
USERPROFILE = $shortProfile
|
||||
}
|
||||
foreach ($name in @('TEMP', 'TMP', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE')) {
|
||||
$value = Get-Rewrite $result $name
|
||||
# Assert it was rewritten AND that the result is clean. Checking only for
|
||||
# the absence of a tilde passes vacuously on a variable nothing touched.
|
||||
Assert-Equal -Expected $true -Actual ($value.StartsWith($longRoot)) -Label "$name is rebuilt on the long profile root"
|
||||
Assert-Equal -Expected $false -Actual ($value -match '~\d') -Label "$name no longer carries an 8.3 alias"
|
||||
}
|
||||
|
||||
# ...and the install paths derived from them are re-derived, not left short.
|
||||
# This is the difference between "the build works" and "the installer stops
|
||||
# claiming a successful build failed". Composed with literal backslashes
|
||||
# because that is how install.ps1 itself builds the default Windows path.
|
||||
$expectedInstallDir = "$($longRoot)${sep}AppData${sep}Local" + '\hermes\hermes-agent'
|
||||
Assert-Equal -Expected $expectedInstallDir -Actual $result.InstallDir -Label "InstallDir is re-derived from the long LOCALAPPDATA"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- substitution is scoped to the profile folder --"
|
||||
|
||||
# We can only prove the long spelling of the profile root itself. A short
|
||||
# component anywhere else must be left exactly as the caller set it.
|
||||
$belowProfile = Join-Parts @($profileDir, 'DEEPLY~1', 'Temp')
|
||||
$result = Invoke-Normalization @{ TEMP = $belowProfile; TMP = $belowProfile }
|
||||
Assert-Equal -Expected '<not rewritten>' -Actual (Get-Rewrite $result 'TEMP') -Label "a short component below the profile root is left alone"
|
||||
|
||||
# A custom TEMP on another volume has no profile root to substitute.
|
||||
$otherVolume = Join-Parts @("D:", 'SHORT~1', 'Temp')
|
||||
$result = Invoke-Normalization @{ TEMP = $otherVolume; TMP = $otherVolume }
|
||||
Assert-Equal -Expected '<not rewritten>' -Actual (Get-Rewrite $result 'TEMP') -Label "short TEMP outside the profile is left alone"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "-- an explicit -InstallDir is normalized, never replaced --"
|
||||
|
||||
$result = Invoke-Normalization -Environment @{ TEMP = $shortTemp; TMP = $shortTemp } `
|
||||
-ExtraArgs @('-InstallDir', (Join-Path $shortProfile 'custom-hermes'))
|
||||
Assert-Equal -Expected (Join-Path $longRoot 'custom-hermes') -Actual $result.InstallDir -Label "explicit -InstallDir keeps the caller's directory, on the long root"
|
||||
|
||||
# --- Summary ---------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) {
|
||||
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "All 8.3 short-path normalization tests passed." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
# Behavioral tests for install.ps1 system Node/npm compatibility selection.
|
||||
#
|
||||
# The installer is dot-sourced without running its entry point, then external
|
||||
# commands and downloads are replaced with deterministic in-process stubs.
|
||||
# This exercises the shipped range parser and Test-Node acceptance gate without
|
||||
# changing PATH, installing software, or touching the user's Hermes home.
|
||||
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
$installScript = Join-Path $repoRoot 'scripts\install.ps1'
|
||||
$testRoot = Join-Path $env:TEMP ("hermes-node-compatibility-test-" + [Guid]::NewGuid().ToString('N'))
|
||||
$HermesHome = Join-Path $testRoot 'home'
|
||||
$InstallDir = Join-Path $testRoot 'missing-checkout'
|
||||
. $installScript -HermesHome $HermesHome -InstallDir $InstallDir
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$script:Failures = 0
|
||||
function Assert-Equal {
|
||||
param($Expected, $Actual, [string]$Label)
|
||||
if ($Expected -ceq $Actual) {
|
||||
Write-Host "PASS: $Label"
|
||||
} else {
|
||||
Write-Host "FAIL: $Label"
|
||||
Write-Host " expected: [$Expected]"
|
||||
Write-Host " actual: [$Actual]"
|
||||
$script:Failures++
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host '-- npm range evaluation --'
|
||||
$supportedRange = Get-NpmRange
|
||||
Assert-Equal '<11.10.0 || >=11.17.0' $supportedRange 'fresh-install fallback matches the supported npm range'
|
||||
Assert-Equal $true (Test-NpmVersionOk '10.9.8') 'bundled npm 10.9.8 is accepted before clone'
|
||||
Assert-Equal $true (Test-NpmVersionOk '11.9.9') 'lower alternative is accepted'
|
||||
Assert-Equal $false (Test-NpmVersionOk '11.10.0') 'excluded band starts at 11.10.0'
|
||||
Assert-Equal $false (Test-NpmVersionOk '11.16.0') 'reported npm 11.16.0 is rejected'
|
||||
Assert-Equal $true (Test-NpmVersionOk '11.17.0') 'upper alternative starts at 11.17.0'
|
||||
Assert-Equal $false (Test-NpmVersionOk 'not-a-version') 'malformed version fails closed'
|
||||
Assert-Equal $false (Test-NpmVersionOk '12.0.0' '^12.0.0') 'unsupported range syntax fails closed'
|
||||
|
||||
# Controlled command surface used by the real Test-Node function.
|
||||
$script:FakeNpmAvailable = $true
|
||||
$script:FakeNpmVersion = '11.16.0'
|
||||
$script:FakeNodeVersion = 'v24.18.0'
|
||||
$script:DownloadAttempts = 0
|
||||
$script:HasNode = $null
|
||||
$NodeVersion = '22'
|
||||
|
||||
function node { $script:FakeNodeVersion }
|
||||
function npm.cmd { $script:FakeNpmVersion }
|
||||
function Get-Command {
|
||||
[CmdletBinding()]
|
||||
param([string]$Name)
|
||||
|
||||
switch ($Name) {
|
||||
'node' {
|
||||
return Microsoft.PowerShell.Core\Get-Command node -CommandType Function
|
||||
}
|
||||
'npm.cmd' {
|
||||
if ($script:FakeNpmAvailable) {
|
||||
return Microsoft.PowerShell.Core\Get-Command npm.cmd -CommandType Function
|
||||
}
|
||||
return $null
|
||||
}
|
||||
'npm' { return $null }
|
||||
'winget' { return $null }
|
||||
default { return $null }
|
||||
}
|
||||
}
|
||||
function Ensure-NodeExeOnPath { $true }
|
||||
function Get-WindowsArch { 'x64' }
|
||||
function Invoke-WebRequest {
|
||||
$script:DownloadAttempts++
|
||||
throw 'network disabled by test'
|
||||
}
|
||||
function Write-Info { param([string]$Message) }
|
||||
function Write-Warn { param([string]$Message) }
|
||||
function Write-Success { param([string]$Message) }
|
||||
|
||||
function Invoke-SystemNodeProbe {
|
||||
param(
|
||||
[string]$NodeVersion,
|
||||
[string]$NpmVersion,
|
||||
[bool]$NpmAvailable = $true
|
||||
)
|
||||
|
||||
$script:FakeNodeVersion = $NodeVersion
|
||||
$script:FakeNpmVersion = $NpmVersion
|
||||
$script:FakeNpmAvailable = $NpmAvailable
|
||||
$script:DownloadAttempts = 0
|
||||
$script:HasNode = $null
|
||||
[void](Test-Node)
|
||||
return [pscustomobject]@{
|
||||
HasNode = $script:HasNode
|
||||
DownloadAttempts = $script:DownloadAttempts
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '-- system Node acceptance --'
|
||||
$result = Invoke-SystemNodeProbe 'v24.18.0' '11.17.0'
|
||||
Assert-Equal $true $result.HasNode 'compatible system Node/npm is accepted'
|
||||
Assert-Equal 0 $result.DownloadAttempts 'compatible system npm avoids managed download'
|
||||
|
||||
$result = Invoke-SystemNodeProbe 'v22.22.0' '10.9.8'
|
||||
Assert-Equal $true $result.HasNode 'minimum Node with bundled npm is accepted'
|
||||
Assert-Equal 0 $result.DownloadAttempts 'bundled npm avoids managed download'
|
||||
|
||||
$result = Invoke-SystemNodeProbe 'v24.18.0' '11.16.0'
|
||||
Assert-Equal $false $result.HasNode 'incompatible system npm is not accepted'
|
||||
Assert-Equal 1 $result.DownloadAttempts 'incompatible system npm falls through to managed Node'
|
||||
|
||||
$result = Invoke-SystemNodeProbe 'v24.18.0' '' $false
|
||||
Assert-Equal $false $result.HasNode 'missing system npm is not accepted'
|
||||
Assert-Equal 1 $result.DownloadAttempts 'missing system npm falls through to managed Node'
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '-- managed npm reuse --'
|
||||
$managedDir = Join-Path $testRoot 'managed-node'
|
||||
New-Item -ItemType Directory -Force -Path $managedDir | Out-Null
|
||||
$managedNpm = Join-Path $managedDir 'npm.cmd'
|
||||
@'
|
||||
@echo off
|
||||
if "%~1"=="--version" (
|
||||
echo 10.9.8
|
||||
exit /b 0
|
||||
)
|
||||
exit /b 42
|
||||
'@ | Set-Content -LiteralPath $managedNpm -Encoding Ascii
|
||||
Assert-Equal $true (Update-ManagedNpm $managedDir) 'compatible managed npm skips the upgrade command'
|
||||
|
||||
if ($script:Failures -gt 0) {
|
||||
Write-Host ''
|
||||
Write-Host "$script:Failures assertion(s) failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'all assertions passed'
|
||||
|
||||
if (Test-Path $testRoot) {
|
||||
Remove-Item -LiteralPath $testRoot -Recurse -Force
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# Smoke tests for the install.ps1 stage protocol.
|
||||
#
|
||||
# Run from a PowerShell prompt:
|
||||
#
|
||||
# powershell -NoProfile -ExecutionPolicy Bypass -File scripts/tests/test-install-ps1-stage-protocol.ps1
|
||||
#
|
||||
# These tests only exercise the metadata surface (-ProtocolVersion, -Manifest,
|
||||
# unknown -Stage handling). They DO NOT actually run any install stages --
|
||||
# those have heavy side effects (winget, git clone, pip install, PATH writes)
|
||||
# and are out of scope for a unit smoke test. All three metadata commands
|
||||
# below return without invoking Main / Invoke-AllStages.
|
||||
#
|
||||
# To exercise real install stages, drive the script from a clean VM.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
|
||||
$installScript = Join-Path $repoRoot "scripts\install.ps1"
|
||||
|
||||
if (-not (Test-Path $installScript)) {
|
||||
throw "Could not locate install.ps1 at $installScript"
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
function Assert-Equal {
|
||||
param([Parameter(Mandatory=$true)] $Expected,
|
||||
[Parameter(Mandatory=$true)] $Actual,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if ($Expected -ne $Actual) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
Write-Host " expected: $Expected"
|
||||
Write-Host " actual: $Actual"
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
function Assert-True {
|
||||
param([Parameter(Mandatory=$true)] $Condition,
|
||||
[Parameter(Mandatory=$true)] [string]$Label)
|
||||
if (-not $Condition) {
|
||||
Write-Host "FAIL: $Label" -ForegroundColor Red
|
||||
$script:failures++
|
||||
} else {
|
||||
Write-Host "OK: $Label" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -ProtocolVersion emits a single integer
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -ProtocolVersion --"
|
||||
$output = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -ProtocolVersion
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-ProtocolVersion exits 0"
|
||||
Assert-True ($output -match '^\d+$') -Label "-ProtocolVersion emits an integer (got: $output)"
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: -Manifest emits valid JSON with expected shape
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Manifest --"
|
||||
$manifestJson = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Manifest
|
||||
Assert-Equal -Expected 0 -Actual $LASTEXITCODE -Label "-Manifest exits 0"
|
||||
|
||||
$manifest = $null
|
||||
try {
|
||||
$manifest = $manifestJson | ConvertFrom-Json
|
||||
Assert-True $true -Label "-Manifest output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "-Manifest output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($manifest) {
|
||||
Assert-True ($manifest.protocol_version -is [int] -or $manifest.protocol_version -is [long]) `
|
||||
-Label "manifest.protocol_version is an integer"
|
||||
Assert-True ($manifest.stages.Count -gt 0) -Label "manifest.stages is non-empty"
|
||||
|
||||
# Every stage has the four required fields
|
||||
$allValid = $true
|
||||
foreach ($stage in $manifest.stages) {
|
||||
foreach ($field in @("name", "title", "category", "needs_user_input")) {
|
||||
if (-not ($stage.PSObject.Properties.Name -contains $field)) {
|
||||
Write-Host " stage missing field '$field': $($stage | ConvertTo-Json -Compress)" -ForegroundColor Red
|
||||
$allValid = $false
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert-True $allValid -Label "every stage has name/title/category/needs_user_input"
|
||||
|
||||
# Specific stage names that the GUI driver will rely on
|
||||
$names = $manifest.stages | ForEach-Object { $_.name }
|
||||
foreach ($expected in @("uv", "python", "git", "venv", "dependencies", "configure", "gateway")) {
|
||||
Assert-True ($names -contains $expected) -Label "manifest contains stage '$expected'"
|
||||
}
|
||||
|
||||
# The two known-interactive stages must declare needs_user_input
|
||||
$interactive = $manifest.stages | Where-Object { $_.needs_user_input } | ForEach-Object { $_.name }
|
||||
Assert-True ($interactive -contains "configure") -Label "'configure' stage flagged needs_user_input"
|
||||
Assert-True ($interactive -contains "gateway") -Label "'gateway' stage flagged needs_user_input"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Test: unknown stage name -> exit 2, structured JSON error
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host "-- -Stage with unknown name --"
|
||||
$errOutput = & powershell -NoProfile -ExecutionPolicy Bypass -File $installScript -Stage "does-not-exist"
|
||||
Assert-Equal -Expected 2 -Actual $LASTEXITCODE -Label "unknown -Stage exits 2"
|
||||
|
||||
$errFrame = $null
|
||||
try {
|
||||
$errFrame = $errOutput | ConvertFrom-Json
|
||||
Assert-True $true -Label "unknown-stage output parses as JSON"
|
||||
} catch {
|
||||
Assert-True $false -Label "unknown-stage output parses as JSON (parse error: $_)"
|
||||
}
|
||||
|
||||
if ($errFrame) {
|
||||
Assert-Equal -Expected $false -Actual $errFrame.ok -Label "unknown-stage frame has ok=false"
|
||||
Assert-Equal -Expected "does-not-exist" -Actual $errFrame.stage -Label "unknown-stage frame echoes stage name"
|
||||
Assert-True ($errFrame.reason -match "unknown stage") -Label "unknown-stage frame explains why"
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Summary
|
||||
# -----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) {
|
||||
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
|
||||
exit 1
|
||||
} else {
|
||||
Write-Host "All smoke tests passed." -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
Reference in New Issue
Block a user