Files
prism/scripts/test-windows-installer.ps1
2026-08-17 21:13:28 -04:00

256 lines
9.6 KiB
PowerShell

[CmdletBinding()]
param(
[string]$InstallerPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') {
throw 'The Windows installer smoke test can only run on Windows.'
}
if ($env:GITHUB_ACTIONS -ne 'true') {
throw 'This test changes the machine PATH and is restricted to the disposable GitHub Actions runner.'
}
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'The Windows installer smoke test requires an elevated process.'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($InstallerPath)) {
$installerCandidates = @(
Get-ChildItem -LiteralPath (Join-Path $repoRoot 'dist') -Filter '*.exe' -File |
Where-Object { $_.Name -like '*Setup*.exe' }
)
if ($installerCandidates.Count -ne 1) {
throw "Expected one NSIS setup executable in dist, found $($installerCandidates.Count)."
}
$InstallerPath = $installerCandidates[0].FullName
}
$InstallerPath = (Resolve-Path -LiteralPath $InstallerPath).Path
$tempRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
[IO.Path]::GetTempPath()
} else {
$env:RUNNER_TEMP
}
$testId = [Guid]::NewGuid().ToString('N')
$installRoot = Join-Path $tempRoot "Prism-$testId Installer Smoke"
$tuiDirectory = Join-Path $installRoot 'resources\tui'
$tuiExecutable = Join-Path $tuiDirectory 'prism-tui.exe'
$uninstallerPath = Join-Path $installRoot 'Uninstall Prism.exe'
$legacyEntry = ($tuiDirectory -split '\s', 2)[0]
$machinePathBefore = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$uninstalled = $false
$legacyDirectoryCreated = $false
function Get-PathEntries {
param([AllowNull()][string]$Value)
return @($Value -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
}
function Assert-PathEntriesEqual {
param(
[string[]]$Expected,
[string[]]$Actual,
[string]$Context
)
if ($Expected.Count -ne $Actual.Count) {
throw "$Context entry count mismatch. Expected $($Expected.Count), got $($Actual.Count)."
}
for ($index = 0; $index -lt $Expected.Count; $index += 1) {
if (-not [string]::Equals($Expected[$index], $Actual[$index], [StringComparison]::OrdinalIgnoreCase)) {
throw "$Context differs at index $index. Expected '$($Expected[$index])', got '$($Actual[$index])'."
}
}
}
function Invoke-CheckedProcess {
param(
[Parameter(Mandatory)][string]$FilePath,
[string]$Arguments = '',
[hashtable]$Environment = @{},
[switch]$CaptureOutput
)
$startInfo = New-Object Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath
$startInfo.Arguments = $Arguments
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.WorkingDirectory = $repoRoot
$startInfo.RedirectStandardOutput = $CaptureOutput
$startInfo.RedirectStandardError = $CaptureOutput
foreach ($name in $Environment.Keys) {
$startInfo.EnvironmentVariables[$name] = [string]$Environment[$name]
}
$process = New-Object Diagnostics.Process
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "Could not start $FilePath."
}
$stdout = ''
$stderr = ''
if ($CaptureOutput) {
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
}
$process.WaitForExit()
if ($process.ExitCode -ne 0) {
throw "$FilePath exited with code $($process.ExitCode).`n$stdout`n$stderr"
}
return [pscustomobject]@{
Stdout = $stdout
Stderr = $stderr
}
}
function Remove-DirectoryWithRetry {
param(
[Parameter(Mandatory)][string]$LiteralPath,
[int]$MaximumAttempts = 10,
[int]$DelayMilliseconds = 500
)
for ($attempt = 1; $attempt -le $MaximumAttempts; $attempt += 1) {
if (-not (Test-Path -LiteralPath $LiteralPath)) {
return
}
try {
Remove-Item -LiteralPath $LiteralPath -Recurse -Force -ErrorAction Stop
return
} catch {
if ($attempt -eq $MaximumAttempts) {
throw
}
Start-Sleep -Milliseconds $DelayMilliseconds
}
}
}
try {
if ([string]::Equals($legacyEntry, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase)) {
throw "The smoke-test installation path must contain whitespace: $installRoot"
}
if (Test-Path -LiteralPath $legacyEntry -PathType Container) {
throw "Refusing to use an existing directory as the legacy PATH fixture: $legacyEntry"
}
$originalEntries = @(Get-PathEntries $machinePathBefore)
foreach ($entry in $originalEntries) {
if ([string]::Equals($entry, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase) -or
[string]::Equals($entry, $legacyEntry, [StringComparison]::OrdinalIgnoreCase)) {
throw "The machine PATH already contains a smoke-test entry: $entry"
}
}
$seededEntries = @($originalEntries) + $legacyEntry
[Environment]::SetEnvironmentVariable('Path', ($seededEntries -join ';'), 'Machine')
# NSIS requires /D to be the final argument and consumes the remainder of the
# command line as the install directory, including spaces.
Invoke-CheckedProcess -FilePath $InstallerPath -Arguments "/S /D=$installRoot" | Out-Null
if (-not (Test-Path -LiteralPath $tuiExecutable -PathType Leaf)) {
throw "The installer did not install prism-tui.exe at $tuiExecutable."
}
if (-not (Test-Path -LiteralPath $uninstallerPath -PathType Leaf)) {
throw "The installer did not create the expected uninstaller at $uninstallerPath."
}
$installedEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine')))
$expectedInstalledEntries = @($originalEntries) + $tuiDirectory
Assert-PathEntriesEqual -Expected $expectedInstalledEntries -Actual $installedEntries -Context 'Installed machine PATH'
$matchingTuiEntries = @(
$installedEntries | Where-Object {
[string]::Equals($_, $tuiDirectory, [StringComparison]::OrdinalIgnoreCase)
}
)
if ($matchingTuiEntries.Count -ne 1) {
throw "Expected one Prism TUI PATH entry, found $($matchingTuiEntries.Count)."
}
if ($installedEntries -icontains $legacyEntry) {
throw "The installer left the legacy truncated PATH entry in place: $legacyEntry"
}
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
$launchPath = (@($installedEntries) + @(Get-PathEntries $userPath)) -join ';'
$launchResult = Invoke-CheckedProcess -FilePath $env:ComSpec `
-Arguments '/D /C prism-tui.exe --version' `
-Environment @{ Path = $launchPath } `
-CaptureOutput
if ($launchResult.Stdout.Trim() -notmatch '^prism-tui\s+') {
throw "prism-tui did not return the expected version output: $($launchResult.Stdout.Trim())"
}
# Reinstall with the legacy prefix backed by a real directory. The safe
# migration must now preserve that entry while continuing to deduplicate the
# complete Prism TUI path.
New-Item -ItemType Directory -Path $legacyEntry | Out-Null
$legacyDirectoryCreated = $true
[Environment]::SetEnvironmentVariable('Path', ((@($installedEntries) + $legacyEntry) -join ';'), 'Machine')
Invoke-CheckedProcess -FilePath $InstallerPath -Arguments "/S /D=$installRoot" | Out-Null
$reinstalledEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine')))
$expectedReinstalledEntries = @($originalEntries) + $legacyEntry + $tuiDirectory
Assert-PathEntriesEqual -Expected $expectedReinstalledEntries -Actual $reinstalledEntries -Context 'Reinstalled machine PATH'
# _?= prevents NSIS from spawning a temporary uninstaller and exiting early,
# so WaitForExit observes the process that is actually removing the files.
# NSIS requires this to be the final argument and forbids quoting its value.
Invoke-CheckedProcess -FilePath $uninstallerPath -Arguments "/S _?=$installRoot" | Out-Null
$uninstalled = $true
$uninstalledEntries = @(Get-PathEntries ([Environment]::GetEnvironmentVariable('Path', 'Machine')))
$expectedUninstalledEntries = @($originalEntries) + $legacyEntry
Assert-PathEntriesEqual -Expected $expectedUninstalledEntries -Actual $uninstalledEntries -Context 'Uninstalled machine PATH'
if ($uninstalledEntries -icontains $tuiDirectory) {
throw "The uninstaller left the Prism TUI PATH entry in place: $tuiDirectory"
}
Write-Host "Windows installer PATH smoke test passed for $InstallerPath"
} finally {
if (-not $uninstalled -and (Test-Path -LiteralPath $uninstallerPath -PathType Leaf)) {
try {
Invoke-CheckedProcess -FilePath $uninstallerPath -Arguments "/S _?=$installRoot" | Out-Null
} catch {
Write-Warning "Smoke-test uninstall cleanup failed: $($_.Exception.Message)"
}
}
[Environment]::SetEnvironmentVariable('Path', $machinePathBefore, 'Machine')
if (Test-Path -LiteralPath $installRoot) {
$fullTempRoot = [IO.Path]::GetFullPath($tempRoot).TrimEnd('\') + '\'
$fullInstallRoot = [IO.Path]::GetFullPath($installRoot)
if ($fullInstallRoot.StartsWith($fullTempRoot, [StringComparison]::OrdinalIgnoreCase)) {
Remove-DirectoryWithRetry -LiteralPath $fullInstallRoot
} else {
Write-Warning "Refusing to remove unexpected smoke-test directory: $fullInstallRoot"
}
}
if ($legacyDirectoryCreated -and (Test-Path -LiteralPath $legacyEntry -PathType Container)) {
$fullTempRoot = [IO.Path]::GetFullPath($tempRoot).TrimEnd('\') + '\'
$fullLegacyEntry = [IO.Path]::GetFullPath($legacyEntry)
if ($fullLegacyEntry.StartsWith($fullTempRoot, [StringComparison]::OrdinalIgnoreCase)) {
Remove-DirectoryWithRetry -LiteralPath $fullLegacyEntry
} else {
Write-Warning "Refusing to remove unexpected legacy fixture directory: $fullLegacyEntry"
}
}
}