返回 DeepSeek-Reasonix
test-windows-installer-startup.ps1
根目录 / scripts / test-windows-installer-startup.ps1
1 [CmdletBinding()]
2 param(
3 [Parameter(Mandatory=$true)][string]$InstallerPath,
4 [Parameter(Mandatory=$true)][string]$ExpectedVersion,
5 [string]$FixtureBuilderPath = '',
6 [string]$EvidenceDirectory = (Join-Path $env:TEMP ('reasonix-installer-' + [guid]::NewGuid().ToString('N'))),
7 [switch]$DisposableEnvironment
8 )
9
10 . (Join-Path $PSScriptRoot 'windows-acceptance-environment.ps1')
11
12 function Get-DefaultReasonixDataHome {
13 return (Join-Path $env:APPDATA 'reasonix')
14 }
15
16 function Get-InstallerIntegrationPaths {
17 (Join-Path ([Environment]::GetFolderPath('DesktopDirectory', 'DoNotVerify')) 'Reasonix.lnk')
18 (Join-Path ([Environment]::GetFolderPath('Programs', 'DoNotVerify')) 'Reasonix.lnk')
19 foreach ($hive in @('HKCU:', 'HKLM:')) {
20 foreach ($software in @('Software', 'Software\WOW6432Node')) {
21 foreach ($product in @('ReasonixReasonix', 'Reasonix')) {
22 "$hive\$software\Microsoft\Windows\CurrentVersion\Uninstall\$product"
23 }
24 }
25 }
26 }
27
28 function Assert-InstallerTestAccount {
29 param([switch]$DisposableEnvironment)
30 if ($env:OS -ne 'Windows_NT') { throw 'Installer acceptance requires Windows.' }
31 $hostedRunner = $env:GITHUB_ACTIONS -eq 'true' -and $env:RUNNER_ENVIRONMENT -eq 'github-hosted'
32 if (-not $hostedRunner -and -not $DisposableEnvironment) {
33 throw 'Use a disposable Windows user or VM snapshot and pass -DisposableEnvironment. Installation changes account-wide shortcuts and registration.'
34 }
35
36 # /D isolates payload files only. NSIS still owns account-wide registration,
37 # shortcuts and legacy WebView data, including during uninstall.
38 $protectedPaths = @(Get-InstallerIntegrationPaths) + @(
39 (Join-Path $env:LOCALAPPDATA 'Programs\Reasonix'),
40 (Join-Path $env:APPDATA 'reasonix-desktop.exe'),
41 (Get-DefaultReasonixDataHome)
42 )
43 foreach ($path in $protectedPaths) {
44 if (Test-Path -LiteralPath $path) {
45 throw 'Existing Reasonix installation, shortcut or legacy data detected; use a clean disposable Windows account.'
46 }
47 }
48 if (@(Get-Process -Name Reasonix,reasonix-desktop,reasonix-launcher -ErrorAction SilentlyContinue).Count -ne 0) {
49 throw 'Reasonix is running; use a clean disposable Windows account.'
50 }
51 }
52
53 function Install-Reasonix {
54 param([string]$Installer, [string]$InstallRoot)
55 $process = Start-Process -FilePath $Installer -ArgumentList @('/S', "/D=$InstallRoot") -PassThru -Wait
56 if ($process.ExitCode -ne 0) { throw "Installer failed with exit code $($process.ExitCode)" }
57 }
58
59 function Assert-InstalledIdentity {
60 param([string]$InstallRoot, [string]$ExpectedVersion)
61 $currentPath = Join-Path $InstallRoot 'current.json'
62 $current = Get-Content -LiteralPath $currentPath -Raw | ConvertFrom-Json
63 $expectedDir = "versions/$ExpectedVersion"
64 if ($current.schemaVersion -ne 1 -or $current.activeVersion -ne $ExpectedVersion -or $current.activeDir.Replace('\', '/') -ne $expectedDir) {
65 throw "Installed current.json does not preserve release identity: $($current | ConvertTo-Json -Compress)"
66 }
67
68 $releaseDir = Join-Path $InstallRoot ($current.activeDir -replace '/', [IO.Path]::DirectorySeparatorChar)
69 if (-not (Test-Path -LiteralPath $releaseDir -PathType Container)) {
70 throw "Installed release directory is missing: $releaseDir"
71 }
72 $build = Get-Content -LiteralPath (Join-Path $releaseDir 'app\resources\build.json') -Raw | ConvertFrom-Json
73 if ($build.version -ne $ExpectedVersion) {
74 throw "Packaged shell identity does not match installed identity: build.json=$($build.version), current.json=$($current.activeVersion)"
75 }
76 $registration = Get-ItemProperty -LiteralPath 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ReasonixReasonix'
77 if ($registration.DisplayVersion -ne $ExpectedVersion.Substring(1) -or $registration.InstallLocation -ne $InstallRoot) {
78 throw 'Uninstall registration does not match the tested version and installation.'
79 }
80 return @{ Current = $current; ReleaseDir = $releaseDir }
81 }
82
83 function Invoke-InstalledRuntimeAcceptance {
84 param([string]$InstallRoot, [string]$ExpectedVersion, [string]$ArtifactPath, [string]$EvidenceDirectory)
85 & (Join-Path $PSScriptRoot 'test-windows-startup-recovery.ps1') @PSBoundParameters | Out-Host
86 }
87
88 function Assert-UninstalledState {
89 param([string]$InstallRoot, [hashtable]$DataSnapshot)
90 # The activation lock or user-owned files may legitimately keep the root
91 # directory nonempty. Every program entry and version payload must be gone.
92 foreach ($name in @('versions', 'app', 'current.json', 'Reasonix.exe', 'reasonix-launcher.exe',
93 'reasonix-desktop.exe', 'reasonix-cli.exe', 'reasonix-update-helper.exe', 'reasonix-guard.exe', 'uninstall.exe')) {
94 if (Test-Path -LiteralPath (Join-Path $InstallRoot $name)) {
95 throw "Uninstall left program files: $name"
96 }
97 }
98 foreach ($path in Get-InstallerIntegrationPaths) {
99 if (Test-Path -LiteralPath $path) { throw 'Uninstall left a shortcut or registration.' }
100 }
101 if (@(Get-Process -Name Reasonix,reasonix-desktop,reasonix-launcher -ErrorAction SilentlyContinue).Count -ne 0) {
102 throw 'Uninstall left Reasonix processes running.'
103 }
104 foreach ($path in $DataSnapshot.Keys) {
105 if (-not (Test-Path -LiteralPath $path -PathType Leaf) -or
106 (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash -ne $DataSnapshot[$path]) {
107 throw 'Uninstall removed or changed preserved user data.'
108 }
109 }
110 }
111
112 function Invoke-WindowsInstallerAcceptance {
113 param([string]$InstallerPath, [string]$ExpectedVersion, [string]$FixtureBuilderPath, [string]$EvidenceDirectory, [switch]$DisposableEnvironment)
114 $ErrorActionPreference = 'Stop'
115 if ($ExpectedVersion -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?$') {
116 throw "ExpectedVersion is not a canonical Reasonix version: $ExpectedVersion"
117 }
118 # Reject before creating fixtures or launching any installer, even with the
119 # explicit disposable-environment flag. Never back up/overwrite a real install.
120 Assert-InstallerTestAccount -DisposableEnvironment:$DisposableEnvironment
121 $installer = (Resolve-Path -LiteralPath $InstallerPath).Path
122 $artifactHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash
123 $EvidenceDirectory = [IO.Path]::GetFullPath($EvidenceDirectory)
124 if (Test-Path -LiteralPath $EvidenceDirectory) {
125 throw 'Installer acceptance directory must be new; refusing to overwrite an existing fixture.'
126 }
127 New-Item -ItemType Directory -Path $EvidenceDirectory | Out-Null
128 $install = Join-Path $EvidenceDirectory 'installed'
129
130 $acceptanceEnvironment = Enter-WindowsAcceptanceEnvironment -DataHome (Join-Path $EvidenceDirectory 'installation-home')
131 try {
132 # Include the default user-data location: NSIS may use $APPDATA directly
133 # even while the app uses an explicit isolated home. Preflight above refuses
134 # existing default data; this sentinel belongs only to the disposable account.
135 $defaultHome = Get-DefaultReasonixDataHome
136 New-Item -ItemType Directory -Path $defaultHome | Out-Null
137 $defaultSentinel = Join-Path $defaultHome 'acceptance-retention.txt'
138 [IO.File]::WriteAllText($defaultSentinel, [guid]::NewGuid().ToString('N'))
139 $defaultHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $defaultSentinel).Hash
140 Install-Reasonix -Installer $installer -InstallRoot $install
141 $identity = Assert-InstalledIdentity -InstallRoot $install -ExpectedVersion $ExpectedVersion
142 Invoke-InstalledRuntimeAcceptance -InstallRoot $install -ExpectedVersion $ExpectedVersion -ArtifactPath $installer `
143 -EvidenceDirectory (Join-Path $EvidenceDirectory 'fresh-install')
144
145 $repair = 'not applicable'
146 $truncatedVersion = $ExpectedVersion -replace '-.*$', ''
147 if ($truncatedVersion -ne $ExpectedVersion) {
148 # The first installed instance has passed startup AND clean exit before
149 # changing the fixture. Keep the two acceptance reports independent.
150 Move-Item -LiteralPath $identity.ReleaseDir -Destination (Join-Path $install "versions\$truncatedVersion")
151 $pointer = @{
152 schemaVersion = 1
153 activeVersion = $truncatedVersion
154 activeDir = "versions/$truncatedVersion"
155 } | ConvertTo-Json
156 [IO.File]::WriteAllText((Join-Path $install 'current.json'), $pointer, [Text.UTF8Encoding]::new($false))
157 Install-Reasonix -Installer $installer -InstallRoot $install
158 $identity = Assert-InstalledIdentity -InstallRoot $install -ExpectedVersion $ExpectedVersion
159 Invoke-InstalledRuntimeAcceptance -InstallRoot $install -ExpectedVersion $ExpectedVersion -ArtifactPath $installer `
160 -EvidenceDirectory (Join-Path $EvidenceDirectory 'repaired-install')
161 $repair = 'passed'
162 }
163
164 $oldDataUpgrade = 'not requested'
165 if ($FixtureBuilderPath) {
166 & (Join-Path $PSScriptRoot 'test-windows-upgrade-startup.ps1') `
167 -ApplicationPath (Join-Path $install 'Reasonix.exe') -FixtureBuilderPath $FixtureBuilderPath `
168 -ExpectedVersion $ExpectedVersion -EvidenceDirectory (Join-Path $EvidenceDirectory 'old-data-upgrade')
169 if ($LASTEXITCODE -ne 0) { throw "Old-data upgrade acceptance failed with exit code $LASTEXITCODE" }
170 $oldDataUpgrade = 'passed'
171 }
172
173 # Failure preserves the fixture/logs for diagnosis. Uninstall only after
174 # every tested instance exited normally, never while a failed shell lives.
175 $dataSnapshot = @{ $defaultSentinel = $defaultHash }
176 $homes = @($env:REASONIX_HOME, (Join-Path $EvidenceDirectory 'fresh-install\home'))
177 if ($repair -eq 'passed') { $homes += (Join-Path $EvidenceDirectory 'repaired-install\home') }
178 if ($oldDataUpgrade -eq 'passed') { $homes += (Join-Path $EvidenceDirectory 'old-data-upgrade\home # %20 中文') }
179 foreach ($homePath in $homes) {
180 New-Item -ItemType Directory -Force -Path $homePath | Out-Null
181 [IO.File]::WriteAllText((Join-Path $homePath 'acceptance-retention.txt'), [guid]::NewGuid().ToString('N'))
182 foreach ($file in Get-ChildItem -LiteralPath $homePath -Recurse -File -Force) {
183 $dataSnapshot[$file.FullName] = (Get-FileHash -Algorithm SHA256 -LiteralPath $file.FullName).Hash
184 }
185 }
186 $dataSnapshot | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $EvidenceDirectory 'data-before-uninstall.json') -Encoding utf8
187 $uninstaller = Join-Path $install 'uninstall.exe'
188 if (-not (Test-Path -LiteralPath $uninstaller -PathType Leaf)) { throw 'Installed uninstaller is missing.' }
189 $uninstall = Start-Process -FilePath $uninstaller -ArgumentList '/S' -PassThru -Wait
190 if ($uninstall.ExitCode -ne 0) { throw "Uninstaller failed with exit code $($uninstall.ExitCode)" }
191 Assert-UninstalledState -InstallRoot $install -DataSnapshot $dataSnapshot
192 if ((Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash -ne $artifactHash) {
193 throw 'Installer bytes changed during acceptance; refusing to attest this artifact.'
194 }
195 @{
196 artifact = $artifactHash
197 version = $ExpectedVersion
198 freshInstall = 'passed'
199 repairedInstall = $repair
200 oldDataUpgrade = $oldDataUpgrade
201 uninstall = 'passed'
202 dataPreserved = 'passed'
203 repairFrom = if ($repair -eq 'passed') { $truncatedVersion } else { $null }
204 } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $EvidenceDirectory 'acceptance.json') -Encoding utf8
205 } finally {
206 Restore-WindowsAcceptanceEnvironment -Snapshot $acceptanceEnvironment
207 Write-Host "Installer acceptance evidence: $EvidenceDirectory"
208 }
209 }
210
211 # Dot-sourcing exposes the real orchestration for tests that replace only OS
212 # process/registry boundaries; it never runs an installation.
213 if ($MyInvocation.InvocationName -ne '.') {
214 Invoke-WindowsInstallerAcceptance -InstallerPath $InstallerPath -ExpectedVersion $ExpectedVersion `
215 -FixtureBuilderPath $FixtureBuilderPath -EvidenceDirectory $EvidenceDirectory -DisposableEnvironment:$DisposableEnvironment
216 }
217
217 lines Plain Text