| 1 | [CmdletBinding(DefaultParameterSetName='Portable')] |
| 2 | param( |
| 3 | [Parameter(Mandatory=$true, ParameterSetName='Portable')][string]$PortableZip, |
| 4 | [Parameter(Mandatory=$true, ParameterSetName='Installed')][string]$InstallRoot, |
| 5 | [string]$ExpectedVersion = '', |
| 6 | [string]$ArtifactPath = '', |
| 7 | [string]$DataHome = '', |
| 8 | [string]$ExpectedVisibleText = '', |
| 9 | [switch]$PrepareHistoricalSession, |
| 10 | [string]$EvidenceDirectory = (Join-Path $env:TEMP ('reasonix-recovery-' + [guid]::NewGuid().ToString('N'))) |
| 11 | ) |
| 12 | $ErrorActionPreference = 'Stop' |
| 13 | . (Join-Path $PSScriptRoot 'windows-acceptance-environment.ps1') |
| 14 | . (Join-Path $PSScriptRoot 'windows-upgrade-ui-evidence.ps1') |
| 15 | New-Item -ItemType Directory -Force $EvidenceDirectory | Out-Null |
| 16 | $install = if ($PSCmdlet.ParameterSetName -eq 'Installed') { [IO.Path]::GetFullPath($InstallRoot) } else { Join-Path $EvidenceDirectory 'install' } |
| 17 | $dataHome = if ($DataHome) { [IO.Path]::GetFullPath($DataHome) } else { Join-Path $EvidenceDirectory 'home' } |
| 18 | if ($PSCmdlet.ParameterSetName -eq 'Portable') { |
| 19 | if (Test-Path $install) { throw 'Evidence install directory must be new; refusing to overwrite a running fixture.' } |
| 20 | Expand-Archive -LiteralPath $PortableZip -DestinationPath $install |
| 21 | } elseif (-not (Test-Path -LiteralPath $install -PathType Container)) { |
| 22 | throw "Installed Reasonix directory is missing: $install" |
| 23 | } |
| 24 | $current = Get-Content (Join-Path $install 'current.json') -Raw | ConvertFrom-Json |
| 25 | if ($ExpectedVersion -and $current.activeVersion -ne $ExpectedVersion) { |
| 26 | throw "Installed version mismatch: current.json=$($current.activeVersion), expected=$ExpectedVersion" |
| 27 | } |
| 28 | $shellPath = Join-Path $install ($current.activeDir + '\app\Reasonix.exe') |
| 29 | $launcher = Join-Path $install 'Reasonix.exe' |
| 30 | if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { |
| 31 | $launcher = Join-Path $install 'reasonix-launcher.exe' |
| 32 | } |
| 33 | if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { |
| 34 | throw 'Stable Reasonix launcher is missing from the package root.' |
| 35 | } |
| 36 | |
| 37 | function Read-ShellStatus($process) { |
| 38 | $pipe = [IO.Pipes.NamedPipeClientStream]::new('.', ('reasonix-shell-v1-' + $process.Id), [IO.Pipes.PipeDirection]::In) |
| 39 | try { |
| 40 | $pipe.Connect(500) |
| 41 | $reader = [IO.StreamReader]::new($pipe) |
| 42 | $read = $reader.ReadLineAsync() |
| 43 | if (-not $read.Wait(2000)) { return $null } |
| 44 | if ($read.Result.Length -gt 16384) { throw 'Oversized status response' } |
| 45 | return ($read.Result | ConvertFrom-Json) |
| 46 | } catch { return $null } finally { $pipe.Dispose() } |
| 47 | } |
| 48 | |
| 49 | function Assert-Ready { |
| 50 | foreach ($process in @(Get-Process Reasonix -ErrorAction SilentlyContinue)) { |
| 51 | if ($process.Path -ne $shellPath) { continue } |
| 52 | $status = Read-ShellStatus $process |
| 53 | if ($null -eq $status) { continue } |
| 54 | if ($status.schemaVersion -ne 1 -or $status.product -ne 'com.reasonix.desktop' -or $status.pid -ne $process.Id) { throw 'Status identity mismatch' } |
| 55 | if ($status.lifecycle -ne 'ready' -or $status.service -ne 'ready' -or -not $status.visible -or -not $status.healthy) { throw ('Not ready: ' + ($status | ConvertTo-Json -Compress)) } |
| 56 | if ($status.version -ne $current.activeVersion -or $status.rendererVersion -ne $current.activeVersion) { throw 'Target renderer version mismatch' } |
| 57 | $service = Get-Process -Id $status.servicePID |
| 58 | $expectedService = Join-Path $install ($current.activeDir + '\reasonix-desktop.exe') |
| 59 | if ($service.Path -ne $expectedService) { throw 'Service is outside the active release' } |
| 60 | return @{ Shell=$process; Service=$service; Status=$status } |
| 61 | } |
| 62 | throw 'No verified target shell; inspect preserved logs' |
| 63 | } |
| 64 | |
| 65 | function Assert-VisibleContentAndCapture($process, [string]$text, [string]$outputPath) { |
| 66 | Add-Type -AssemblyName UIAutomationClient |
| 67 | Add-Type -AssemblyName System.Drawing |
| 68 | $deadline = [DateTime]::UtcNow.AddSeconds(20) |
| 69 | $found = $false |
| 70 | $prepared = -not $PrepareHistoricalSession |
| 71 | $root = $null |
| 72 | while ([DateTime]::UtcNow -lt $deadline -and -not $found) { |
| 73 | $process.Refresh() |
| 74 | if ($process.MainWindowHandle -ne 0) { |
| 75 | $root = [Windows.Automation.AutomationElement]::FromHandle($process.MainWindowHandle) |
| 76 | if (-not $prepared) { $prepared = Invoke-PendingHistoricalSession $root } |
| 77 | $found = $prepared -and (Test-VisibleUpgradeHistory $root $text) |
| 78 | } |
| 79 | if (-not $found) { Start-Sleep -Milliseconds 250 } |
| 80 | } |
| 81 | if ($null -eq $root) { throw 'The packaged shell did not expose a native window for upgrade evidence.' } |
| 82 | @(Get-UpgradeUIDescendants $root | Select-Object -First 5000 | ForEach-Object { |
| 83 | @{name=$_.Current.Name; id=$_.Current.AutomationId; offscreen=$_.Current.IsOffscreen} |
| 84 | }) | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath ($outputPath + '.uia.json') -Encoding utf8 |
| 85 | $bounds = $root.Current.BoundingRectangle |
| 86 | if ($bounds.Width -le 0 -or $bounds.Height -le 0) { throw 'The packaged shell window has invalid bounds.' } |
| 87 | $bitmap = [Drawing.Bitmap]::new([int]$bounds.Width, [int]$bounds.Height) |
| 88 | try { |
| 89 | $graphics = [Drawing.Graphics]::FromImage($bitmap) |
| 90 | try { $graphics.CopyFromScreen([int]$bounds.X, [int]$bounds.Y, 0, 0, $bitmap.Size) } finally { $graphics.Dispose() } |
| 91 | $bitmap.Save($outputPath, [Drawing.Imaging.ImageFormat]::Png) |
| 92 | } finally { $bitmap.Dispose() } |
| 93 | if (-not $found) { throw "Packaged UI did not expose expected upgraded history text: $text" } |
| 94 | } |
| 95 | |
| 96 | $acceptanceEnvironment = Enter-WindowsAcceptanceEnvironment -DataHome $dataHome |
| 97 | try { |
| 98 | $attempt = Start-Process $launcher -PassThru -RedirectStandardOutput (Join-Path $EvidenceDirectory 'launcher.stdout.log') -RedirectStandardError (Join-Path $EvidenceDirectory 'launcher.stderr.log') |
| 99 | $null = $attempt.Handle |
| 100 | if (-not $attempt.WaitForExit(40000)) { throw 'Stable launcher timed out; inspect launcher logs' } |
| 101 | if ($attempt.ExitCode -ne 0) { throw ('Stable launcher failed: exit=' + $attempt.ExitCode + '; ' + (Get-Content (Join-Path $EvidenceDirectory 'launcher.stderr.log') -Raw)) } |
| 102 | $first = Assert-Ready |
| 103 | $again = Start-Process $launcher -PassThru |
| 104 | $null = $again.Handle |
| 105 | if (-not $again.WaitForExit(40000) -or $again.ExitCode -ne 0) { throw 'Second launch failed or timed out' } |
| 106 | $second = Assert-Ready |
| 107 | if ($first.Shell.Id -ne $second.Shell.Id -or $first.Service.Id -ne $second.Service.Id -or $first.Status.generation -ne $second.Status.generation) { throw 'Second launch replaced the healthy instance' } |
| 108 | if ($ExpectedVisibleText) { |
| 109 | Assert-VisibleContentAndCapture $first.Shell $ExpectedVisibleText (Join-Path $EvidenceDirectory 'upgraded-history.png') |
| 110 | } |
| 111 | Start-Process $shellPath -ArgumentList '--reasonix-lifecycle-request=quit' | Out-Null |
| 112 | if (-not $first.Shell.WaitForExit(15000) -or -not $first.Service.WaitForExit(1000)) { throw 'Normal exit left a shell or service alive' } |
| 113 | $shellLog = Get-Content (Join-Path $dataHome 'desktop-shell\logs\shell.log') -Raw |
| 114 | if ($shellLog -match 'exit deadline exceeded|killing it|termination failed') { throw 'Forced cleanup is not a normal-exit pass' } |
| 115 | $artifactHash = if ($ArtifactPath) { (Get-FileHash -Algorithm SHA256 $ArtifactPath).Hash } elseif ($PortableZip) { (Get-FileHash -Algorithm SHA256 $PortableZip).Hash } else { '' } |
| 116 | $report = @{ |
| 117 | artifact=$artifactHash |
| 118 | version=$current.activeVersion |
| 119 | OS=[Environment]::OSVersion.VersionString |
| 120 | architecture=$env:PROCESSOR_ARCHITECTURE |
| 121 | shellPID=$first.Shell.Id |
| 122 | servicePID=$first.Service.Id |
| 123 | startup='passed'; secondLaunch='passed'; normalExit='passed'; visibleHistory=if ($ExpectedVisibleText) { 'passed' } else { 'not requested' } |
| 124 | signing='not checked'; installer=if ($PSCmdlet.ParameterSetName -eq 'Installed') { 'passed' } else { 'not exercised' }; recoveryConsent='not exercised' |
| 125 | } |
| 126 | $report | ConvertTo-Json | Set-Content (Join-Path $EvidenceDirectory 'result.json') |
| 127 | $report | ConvertTo-Json |
| 128 | } finally { |
| 129 | Restore-WindowsAcceptanceEnvironment -Snapshot $acceptanceEnvironment |
| 130 | Write-Host "Evidence preserved at $EvidenceDirectory. No forced process cleanup was performed." |
| 131 | } |
| 132 |