返回 DeepSeek-Reasonix
test-windows-installer-startup.test.ps1
根目录 / scripts / test-windows-installer-startup.test.ps1
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
283 lines Plain Text