| 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 |