| 1 | # Exercise the real acceptance orchestration with disposable files and mocked |
| 2 | # process/registry boundaries. This never installs or uninstalls an application. |
| 3 | $ErrorActionPreference = 'Stop' |
| 4 | if ($env:OS -ne 'Windows_NT') { throw 'Run these tests on Windows.' } |
| 5 | . (Join-Path $PSScriptRoot 'test-windows-installer-startup.ps1') -InstallerPath unused -ExpectedVersion v1.2.3 |
| 6 | |
| 7 | $script:testRoot = [IO.Path]::GetFullPath((Join-Path $env:TEMP ('reasonix-installer-tests-' + [guid]::NewGuid().ToString('N')))) |
| 8 | $null = [IO.Directory]::CreateDirectory($script:testRoot) |
| 9 | $script:occupiedPath = '' |
| 10 | $script:running = $false |
| 11 | $script:events = [Collections.Generic.List[string]]::new() |
| 12 | $script:testCount = 0 |
| 13 | |
| 14 | function Assert-Equal($actual, $expected) { |
| 15 | if ($actual -cne $expected) { throw "Expected [$expected], got [$actual]" } |
| 16 | } |
| 17 | function Assert-Throws([scriptblock]$Action, [string]$Message) { |
| 18 | try { & $Action } catch { |
| 19 | if ($_.Exception.Message -notlike "*$Message*") { throw } |
| 20 | return |
| 21 | } |
| 22 | throw "Expected failure containing: $Message" |
| 23 | } |
| 24 | function Get-DefaultReasonixDataHome { |
| 25 | # This OS boundary is redirected to the test fixture, never the VM account. |
| 26 | return (Join-Path $script:testRoot "default-data-$script:testCount") |
| 27 | } |
| 28 | function Test-Path { |
| 29 | param([string]$Path, [string]$LiteralPath, [string]$PathType) |
| 30 | $candidate = if ($PSBoundParameters.ContainsKey('LiteralPath')) { $LiteralPath } else { $Path } |
| 31 | if ([string]::IsNullOrEmpty($candidate)) { return $false } |
| 32 | if ($candidate -eq $script:occupiedPath) { return $true } |
| 33 | if ($candidate.StartsWith($script:testRoot, [StringComparison]::OrdinalIgnoreCase)) { |
| 34 | $native = @{} |
| 35 | if ($PSBoundParameters.ContainsKey('LiteralPath')) { $native.LiteralPath = $candidate } else { $native.Path = $candidate } |
| 36 | if ($PSBoundParameters.ContainsKey('PathType')) { $native.PathType = $PathType } |
| 37 | return Microsoft.PowerShell.Management\Test-Path @native |
| 38 | } |
| 39 | # All account state is virtual, including on a developer's existing VM. |
| 40 | return $candidate -eq $script:occupiedPath |
| 41 | } |
| 42 | function Get-Process { |
| 43 | param($Name, $ErrorAction) |
| 44 | if ($script:running) { [pscustomobject]@{ Id = 123 } } |
| 45 | } |
| 46 | function Get-ItemProperty { |
| 47 | param($LiteralPath) |
| 48 | return [pscustomobject]@{ DisplayVersion = $script:version.Substring(1); InstallLocation = $script:installRoot } |
| 49 | } |
| 50 | function Assert-IsolatedEnvironment([string]$HomePath) { |
| 51 | $HomePath = [IO.Path]::GetFullPath($HomePath) |
| 52 | Assert-Equal $env:REASONIX_HOME $HomePath |
| 53 | Assert-Equal $env:REASONIX_STATE_HOME $HomePath |
| 54 | Assert-Equal $env:REASONIX_CACHE_HOME (Join-Path $HomePath 'cache') |
| 55 | Assert-Equal $env:REASONIX_NONINTERACTIVE '1' |
| 56 | foreach ($key in @('REASONIX_DEV','REASONIX_DESKTOP_SERVICE','REASONIX_ELECTRON_DEV_URL', |
| 57 | 'REASONIX_UNKNOWN_TEST_OVERRIDE','NODE_OPTIONS','ELECTRON_RUN_AS_NODE')) { |
| 58 | Assert-Equal ([string][Environment]::GetEnvironmentVariable($key, 'Process')) '' |
| 59 | } |
| 60 | } |
| 61 | function Assert-ParentEnvironment { |
| 62 | Assert-Equal $env:REASONIX_HOME 'untouched-user-home' |
| 63 | Assert-Equal $env:REASONIX_STATE_HOME 'untouched-user-state' |
| 64 | Assert-Equal $env:REASONIX_CACHE_HOME 'untouched-user-cache' |
| 65 | Assert-Equal $env:REASONIX_DEV 'untouched-dev-mode' |
| 66 | Assert-Equal $env:REASONIX_UNKNOWN_TEST_OVERRIDE 'untouched-override' |
| 67 | Assert-Equal $env:NODE_OPTIONS 'untouched-node-options' |
| 68 | Assert-Equal $env:ELECTRON_RUN_AS_NODE '1' |
| 69 | } |
| 70 | function Start-Process { |
| 71 | param([string]$FilePath, $ArgumentList, [switch]$PassThru, [switch]$Wait) |
| 72 | if (-not $FilePath.StartsWith($script:testRoot)) { throw 'Process boundary escaped the fixture.' } |
| 73 | Assert-IsolatedEnvironment (Join-Path $script:evidence 'installation-home') |
| 74 | if ((Split-Path $FilePath -Leaf) -eq 'uninstall.exe') { |
| 75 | $script:events.Add('uninstall') |
| 76 | if ($script:uninstallFault -eq 'noop') { return [pscustomobject]@{ ExitCode = 0 } } |
| 77 | [IO.Directory]::Delete($script:installRoot, $true) |
| 78 | if ($script:uninstallFault -eq 'program') { |
| 79 | $null = [IO.Directory]::CreateDirectory($script:installRoot) |
| 80 | [IO.File]::WriteAllText((Join-Path $script:installRoot 'Reasonix.exe'), 'leftover') |
| 81 | } elseif ($script:uninstallFault -eq 'shortcut') { |
| 82 | $script:occupiedPath = Join-Path ([Environment]::GetFolderPath('Programs', 'DoNotVerify')) 'Reasonix.lnk' |
| 83 | } elseif ($script:uninstallFault -eq 'registration') { |
| 84 | $script:occupiedPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ReasonixReasonix' |
| 85 | } elseif ($script:uninstallFault -eq 'process') { |
| 86 | $script:running = $true |
| 87 | } elseif ($script:uninstallFault -eq 'default-data') { |
| 88 | [IO.Directory]::Delete((Get-DefaultReasonixDataHome), $true) |
| 89 | } elseif ($script:uninstallFault -eq 'runtime-data') { |
| 90 | [IO.Directory]::Delete((Join-Path $script:evidence 'fresh-install\home'), $true) |
| 91 | } elseif ($script:uninstallFault -eq 'modified-data') { |
| 92 | [IO.File]::WriteAllText((Join-Path $script:evidence 'fresh-install\home\conversation.fixture'), 'corrupted') |
| 93 | } |
| 94 | } else { |
| 95 | $script:events.Add('install') |
| 96 | $script:installCount++ |
| 97 | $script:installRoot = $ArgumentList[1].Substring(3) |
| 98 | if ($script:installCount -eq 2) { |
| 99 | $bytes = [IO.File]::ReadAllBytes((Join-Path $script:installRoot 'current.json')) |
| 100 | Assert-Equal ([int]$bytes[0]) 123 # '{', never a PowerShell 5 UTF-8 BOM |
| 101 | $pointer = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json |
| 102 | Assert-Equal $pointer.activeVersion ($script:version -replace '-.*$', '') |
| 103 | } |
| 104 | $releaseDir = Join-Path $script:installRoot ('versions\' + $script:version) |
| 105 | $null = [IO.Directory]::CreateDirectory((Join-Path $releaseDir 'app\resources')) |
| 106 | $pointer = @{ schemaVersion = 1; activeVersion = $script:version; activeDir = "versions/$script:version" } | ConvertTo-Json |
| 107 | [IO.File]::WriteAllText((Join-Path $script:installRoot 'current.json'), $pointer) |
| 108 | [IO.File]::WriteAllText((Join-Path $releaseDir 'app\resources\build.json'), (@{ version = $script:version } | ConvertTo-Json)) |
| 109 | [IO.File]::WriteAllText((Join-Path $script:installRoot 'uninstall.exe'), 'mock') |
| 110 | } |
| 111 | return [pscustomobject]@{ ExitCode = 0 } |
| 112 | } |
| 113 | function Invoke-InstalledRuntimeAcceptance { |
| 114 | param([string]$InstallRoot, [string]$ExpectedVersion, [string]$ArtifactPath, [string]$EvidenceDirectory) |
| 115 | $phase = Split-Path $EvidenceDirectory -Leaf |
| 116 | $script:events.Add($phase) |
| 117 | Assert-Equal $script:installCount $(if ($phase -eq 'fresh-install') { 1 } else { 2 }) |
| 118 | if ($script:failPhase -eq $phase) { throw "runtime failed: $phase" } |
| 119 | $null = [IO.Directory]::CreateDirectory($EvidenceDirectory) |
| 120 | $null = [IO.Directory]::CreateDirectory((Join-Path $EvidenceDirectory 'home')) |
| 121 | [IO.File]::WriteAllText((Join-Path $EvidenceDirectory 'home\conversation.fixture'), 'preserve these fixture bytes') |
| 122 | [IO.File]::WriteAllText((Join-Path $EvidenceDirectory 'result.json'), '{"mock":true}') |
| 123 | if ($script:changeArtifact) { [IO.File]::AppendAllText($ArtifactPath, 'changed') } |
| 124 | } |
| 125 | |
| 126 | function New-TestCase([string]$Version = 'v1.38.9-2') { |
| 127 | $script:testCount++ |
| 128 | $script:version = $Version |
| 129 | $script:evidence = Join-Path $script:testRoot "evidence-$script:testCount" |
| 130 | $script:artifact = Join-Path $script:testRoot "installer-$script:testCount.exe" |
| 131 | [IO.File]::WriteAllText($script:artifact, 'mock installer') |
| 132 | $script:events.Clear() |
| 133 | $script:installCount = 0 |
| 134 | $script:failPhase = '' |
| 135 | $script:changeArtifact = $false |
| 136 | $script:occupiedPath = '' |
| 137 | $script:running = $false |
| 138 | $script:uninstallFault = '' |
| 139 | } |
| 140 | function Invoke-TestCase { |
| 141 | Invoke-WindowsInstallerAcceptance -InstallerPath $script:artifact -ExpectedVersion $script:version ` |
| 142 | -EvidenceDirectory $script:evidence -DisposableEnvironment |
| 143 | } |
| 144 | |
| 145 | $saved = Get-WindowsAcceptanceEnvironment |
| 146 | foreach ($key in @('GITHUB_ACTIONS','RUNNER_ENVIRONMENT')) { |
| 147 | $saved[$key] = [Environment]::GetEnvironmentVariable($key, 'Process') |
| 148 | } |
| 149 | try { |
| 150 | $env:GITHUB_ACTIONS = 'false' |
| 151 | $env:RUNNER_ENVIRONMENT = 'self-hosted' |
| 152 | $env:REASONIX_HOME = 'untouched-user-home' |
| 153 | $env:REASONIX_DEV = 'untouched-dev-mode' |
| 154 | $env:REASONIX_STATE_HOME = 'untouched-user-state' |
| 155 | $env:REASONIX_CACHE_HOME = 'untouched-user-cache' |
| 156 | $env:REASONIX_UNKNOWN_TEST_OVERRIDE = 'untouched-override' |
| 157 | $env:NODE_OPTIONS = 'untouched-node-options' |
| 158 | $env:ELECTRON_RUN_AS_NODE = '1' |
| 159 | New-TestCase |
| 160 | Assert-Throws { Invoke-WindowsInstallerAcceptance -InstallerPath $script:artifact -ExpectedVersion $script:version -EvidenceDirectory $script:evidence } 'disposable Windows' |
| 161 | Assert-Equal $script:events.Count 0 |
| 162 | Assert-Equal (Test-Path -LiteralPath $script:evidence) $false |
| 163 | |
| 164 | $protected = @( |
| 165 | (Join-Path ([Environment]::GetFolderPath('DesktopDirectory', 'DoNotVerify')) 'Reasonix.lnk'), |
| 166 | (Join-Path ([Environment]::GetFolderPath('Programs', 'DoNotVerify')) 'Reasonix.lnk'), |
| 167 | (Join-Path $env:LOCALAPPDATA 'Programs\Reasonix'), |
| 168 | (Join-Path $env:APPDATA 'reasonix-desktop.exe') |
| 169 | ) |
| 170 | foreach ($hive in @('HKCU:', 'HKLM:')) { |
| 171 | foreach ($software in @('Software', 'Software\WOW6432Node')) { |
| 172 | foreach ($product in @('ReasonixReasonix', 'Reasonix')) { |
| 173 | $protected += "$hive\$software\Microsoft\Windows\CurrentVersion\Uninstall\$product" |
| 174 | } |
| 175 | } |
| 176 | } |
| 177 | foreach ($path in $protected) { |
| 178 | New-TestCase |
| 179 | $script:occupiedPath = $path |
| 180 | Assert-Throws { Invoke-TestCase } 'Existing Reasonix' |
| 181 | Assert-Equal $script:events.Count 0 |
| 182 | Assert-Equal (Test-Path -LiteralPath $script:evidence) $false |
| 183 | } |
| 184 | New-TestCase |
| 185 | $script:occupiedPath = Get-DefaultReasonixDataHome |
| 186 | Assert-Throws { Invoke-TestCase } 'Existing Reasonix' |
| 187 | Assert-Equal $script:events.Count 0 |
| 188 | Assert-Equal (Test-Path -LiteralPath $script:evidence) $false |
| 189 | New-TestCase |
| 190 | $script:running = $true |
| 191 | Assert-Throws { Invoke-TestCase } 'Reasonix is running' |
| 192 | Assert-Equal $script:events.Count 0 |
| 193 | |
| 194 | foreach ($version in @('v1.38.9', 'v1.38.9-2', 'v1.38.9-preview.42', 'v1.38.9-rc.1')) { |
| 195 | New-TestCase $version |
| 196 | Invoke-TestCase |
| 197 | $expected = if ($version.Contains('-')) { 'install,fresh-install,install,repaired-install,uninstall' } else { 'install,fresh-install,uninstall' } |
| 198 | Assert-Equal ($script:events -join ',') $expected |
| 199 | $report = Get-Content -LiteralPath (Join-Path $script:evidence 'acceptance.json') -Raw | ConvertFrom-Json |
| 200 | Assert-Equal $report.artifact (Get-FileHash -Algorithm SHA256 -LiteralPath $script:artifact).Hash |
| 201 | Assert-Equal $report.freshInstall 'passed' |
| 202 | Assert-Equal $report.repairedInstall $(if ($version.Contains('-')) { 'passed' } else { 'not applicable' }) |
| 203 | Assert-Equal $report.uninstall 'passed' |
| 204 | Assert-Equal $report.dataPreserved 'passed' |
| 205 | Assert-ParentEnvironment |
| 206 | } |
| 207 | foreach ($phase in @('fresh-install', 'repaired-install')) { |
| 208 | New-TestCase |
| 209 | $script:failPhase = $phase |
| 210 | Assert-Throws { Invoke-TestCase } "runtime failed: $phase" |
| 211 | $expected = if ($phase -eq 'fresh-install') { 'install,fresh-install' } else { 'install,fresh-install,install,repaired-install' } |
| 212 | Assert-Equal ($script:events -join ',') $expected |
| 213 | Assert-Equal (Test-Path -LiteralPath (Join-Path $script:evidence 'acceptance.json')) $false |
| 214 | Assert-ParentEnvironment |
| 215 | } |
| 216 | foreach ($fault in @('noop','program','shortcut','registration','process','default-data','runtime-data','modified-data')) { |
| 217 | New-TestCase |
| 218 | $script:uninstallFault = $fault |
| 219 | Assert-Throws { Invoke-TestCase } 'Uninstall ' |
| 220 | Assert-Equal ($script:events -join ',') 'install,fresh-install,install,repaired-install,uninstall' |
| 221 | Assert-Equal (Test-Path -LiteralPath (Join-Path $script:evidence 'acceptance.json')) $false |
| 222 | Assert-ParentEnvironment |
| 223 | } |
| 224 | |
| 225 | # Exercise the actual runtime entry point in both modes, stopping at its |
| 226 | # first process boundary. This verifies shared isolation wiring and failure |
| 227 | # restoration without launching an Electron instance. |
| 228 | foreach ($mode in @('Installed', 'Portable')) { |
| 229 | New-TestCase |
| 230 | $fixture = Join-Path $script:testRoot "runtime-$script:testCount" |
| 231 | $null = [IO.Directory]::CreateDirectory($fixture) |
| 232 | [IO.File]::WriteAllText((Join-Path $fixture 'current.json'), '{"activeVersion":"v1.2.3","activeDir":"versions/v1.2.3"}') |
| 233 | [IO.File]::WriteAllText((Join-Path $fixture 'Reasonix.exe'), 'never executed') |
| 234 | $parameters = @{ EvidenceDirectory = $script:evidence } |
| 235 | if ($mode -eq 'Installed') { |
| 236 | $parameters.InstallRoot = $fixture |
| 237 | } else { |
| 238 | $zip = Join-Path $script:testRoot "portable-$script:testCount.zip" |
| 239 | Compress-Archive -Path (Join-Path $fixture '*') -DestinationPath $zip |
| 240 | $parameters.PortableZip = $zip |
| 241 | } |
| 242 | Assert-Throws { |
| 243 | $runtimeProbeHome = Join-Path $script:evidence 'home' |
| 244 | function Start-Process { |
| 245 | param([string]$FilePath) |
| 246 | Assert-IsolatedEnvironment $runtimeProbeHome |
| 247 | throw 'Runtime environment probe complete' |
| 248 | } |
| 249 | & (Join-Path $PSScriptRoot 'test-windows-startup-recovery.ps1') @parameters |
| 250 | } 'Runtime environment probe complete' |
| 251 | Assert-ParentEnvironment |
| 252 | } |
| 253 | |
| 254 | # Nested scopes restore the outer isolated home, then the original caller. |
| 255 | New-TestCase |
| 256 | $outerHome = Join-Path $script:testRoot 'unused\..\outer-home' |
| 257 | $outerEnvironment = Enter-WindowsAcceptanceEnvironment -DataHome $outerHome |
| 258 | try { |
| 259 | $innerEnvironment = Enter-WindowsAcceptanceEnvironment -DataHome (Join-Path $script:testRoot 'inner-home') |
| 260 | try { Assert-IsolatedEnvironment (Join-Path $script:testRoot 'inner-home') } |
| 261 | finally { Restore-WindowsAcceptanceEnvironment -Snapshot $innerEnvironment } |
| 262 | Assert-IsolatedEnvironment $outerHome |
| 263 | $env:REASONIX_CREATED_INSIDE_TEST = 'remove on exit' |
| 264 | } finally { Restore-WindowsAcceptanceEnvironment -Snapshot $outerEnvironment } |
| 265 | Assert-ParentEnvironment |
| 266 | Assert-Equal ([string]$env:REASONIX_CREATED_INSIDE_TEST) '' |
| 267 | New-TestCase 'v1.38.9' |
| 268 | $script:changeArtifact = $true |
| 269 | Assert-Throws { Invoke-TestCase } 'Installer bytes changed' |
| 270 | Assert-Equal (Test-Path -LiteralPath (Join-Path $script:evidence 'acceptance.json')) $false |
| 271 | |
| 272 | New-TestCase |
| 273 | $env:GITHUB_ACTIONS = 'true' |
| 274 | $env:RUNNER_ENVIRONMENT = 'github-hosted' |
| 275 | Assert-InstallerTestAccount |
| 276 | $script:occupiedPath = $protected[0] |
| 277 | Assert-Throws { Assert-InstallerTestAccount } 'Existing Reasonix' |
| 278 | Write-Host "PASS: $script:testCount installer acceptance cases (mock processes and registry; no real installation)." |
| 279 | } finally { |
| 280 | Restore-WindowsAcceptanceEnvironment -Snapshot $saved |
| 281 | [IO.Directory]::Delete($script:testRoot, $true) |
| 282 | } |
| 283 |