| 1 | $ErrorActionPreference = 'Stop' |
| 2 | . (Join-Path $PSScriptRoot 'test-windows-upgrade-startup.ps1') |
| 3 | . (Join-Path $PSScriptRoot 'windows-upgrade-ui-evidence.ps1') |
| 4 | |
| 5 | function Assert-True($value, [string]$message) { if (-not $value) { throw $message } } |
| 6 | function Assert-Throws([scriptblock]$action, [string]$message) { |
| 7 | $caught = $false |
| 8 | try { & $action } catch { $caught = $true } |
| 9 | Assert-True $caught $message |
| 10 | } |
| 11 | |
| 12 | $testRoot = Join-Path ([IO.Path]::GetTempPath()) ('reasonix-upgrade-contract-' + [guid]::NewGuid().ToString('N')) |
| 13 | $savedHome = $HOME |
| 14 | try { |
| 15 | $release = Join-Path $testRoot 'install/releases/test/app/resources' |
| 16 | New-Item -ItemType Directory -Path $release -Force | Out-Null |
| 17 | $application = Join-Path $testRoot 'install/Reasonix.exe' |
| 18 | $builder = Join-Path $testRoot 'fixture.exe' |
| 19 | Set-Content -LiteralPath $application -Value 'mock launcher' |
| 20 | Set-Content -LiteralPath $builder -Value 'mock builder' |
| 21 | $version = 'v1.38.9-5' |
| 22 | $commit = '0123456789abcdef0123456789abcdef01234567' |
| 23 | @{activeVersion=$version; activeDir='releases/test'} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $testRoot 'install/current.json') |
| 24 | $buildPath = Join-Path $release 'build.json' |
| 25 | @{version=$version; commit=$commit} | ConvertTo-Json | Set-Content -LiteralPath $buildPath |
| 26 | $script:calls = [Collections.Generic.List[string]]::new() |
| 27 | $script:failPhase = '' |
| 28 | # Only replace native process boundaries; run the real evidence orchestration. |
| 29 | function Invoke-UpgradeFixture([string]$builder, [string[]]$arguments) { |
| 30 | $mode = $arguments[1] |
| 31 | $fixtureHome = $arguments[3] |
| 32 | $report = $arguments[5] |
| 33 | Assert-True ($fixtureHome -ne $HOME -and $fixtureHome.Contains('home # %20 中文')) 'Fixture must use a disposable home.' |
| 34 | if ($mode -eq 'create') { |
| 35 | $script:calls.Add('create') |
| 36 | @{visibleText='assistant-only marker'} | ConvertTo-Json | Set-Content -LiteralPath $report |
| 37 | } else { |
| 38 | $phase = $arguments[7] |
| 39 | $script:calls.Add("verify-$phase") |
| 40 | if ($script:failPhase -eq $phase) { throw 'Injected verification failure' } |
| 41 | } |
| 42 | } |
| 43 | function Invoke-UpgradeStartup([string]$installRoot, [string]$version, [string]$fixtureHome, [string]$text, [string]$evidence, [bool]$prepareHistorical) { |
| 44 | Assert-True ($text -eq 'assistant-only marker') 'Startup must require the assistant marker.' |
| 45 | Assert-True ($prepareHistorical -eq ((Split-Path $evidence -Leaf) -eq 'restart')) 'The first launch must show legacy history unprepared; only the restart may import it.' |
| 46 | $script:calls.Add('startup-' + (Split-Path $evidence -Leaf)) |
| 47 | } |
| 48 | $evidence = Join-Path $testRoot 'success' |
| 49 | Invoke-WindowsUpgradeAcceptance $application $builder $version $evidence |
| 50 | $result = Get-Content -LiteralPath (Join-Path $evidence 'result.json') -Raw | ConvertFrom-Json |
| 51 | Assert-True ($HOME -eq $savedHome) 'Acceptance changed the PowerShell home.' |
| 52 | Assert-True ($result.sourceSha -eq $commit) 'Evidence must read build.json.commit.' |
| 53 | Assert-True ($result.applicationSha256 -eq (Get-FileHash -Algorithm SHA256 -LiteralPath $application).Hash) 'Evidence must hash the tested application.' |
| 54 | Assert-True (($script:calls -join ',') -eq 'create,startup-first,verify-first,startup-restart,verify-restart') 'Upgrade and restart must both be checked in order.' |
| 55 | |
| 56 | foreach ($invalidCommit in @('', 'unknown', '0123456')) { |
| 57 | @{version=$version; commit=$invalidCommit; sourceSha=$commit} | ConvertTo-Json | Set-Content -LiteralPath $buildPath |
| 58 | $badEvidence = Join-Path $testRoot ('invalid-' + [guid]::NewGuid().ToString('N')) |
| 59 | Assert-Throws { Invoke-WindowsUpgradeAcceptance $application $builder $version $badEvidence } 'Missing/invalid commit must fail closed.' |
| 60 | Assert-True (-not (Test-Path -LiteralPath $badEvidence)) 'Invalid package must not get acceptance evidence.' |
| 61 | } |
| 62 | @{version=$version; commit=$commit} | ConvertTo-Json | Set-Content -LiteralPath $buildPath |
| 63 | foreach ($phase in @('first', 'restart')) { |
| 64 | $script:failPhase = $phase |
| 65 | $badEvidence = Join-Path $testRoot "failure-$phase" |
| 66 | Assert-Throws { Invoke-WindowsUpgradeAcceptance $application $builder $version $badEvidence } 'Data verification failure must propagate.' |
| 67 | Assert-True (-not (Test-Path -LiteralPath (Join-Path $badEvidence 'result.json'))) 'Failed verification cannot publish a pass.' |
| 68 | } |
| 69 | |
| 70 | # Model the native tree enumeration boundary. Selection and visibility checks |
| 71 | # below use the same function as packaged Windows acceptance. |
| 72 | function Get-UpgradeUIDescendants($element) { |
| 73 | foreach ($child in $element.Children) { $child; Get-UpgradeUIDescendants $child } |
| 74 | } |
| 75 | function New-UIElement([string]$name, [string]$id='', [bool]$offscreen=$false, [object[]]$children=@()) { |
| 76 | return [pscustomobject]@{ |
| 77 | Current=[pscustomobject]@{Name=$name; AutomationId=$id; IsOffscreen=$offscreen; BoundingRectangle=[pscustomobject]@{Width=200; Height=20}} |
| 78 | Children=$children |
| 79 | } |
| 80 | } |
| 81 | $marker = 'assistant-only marker' |
| 82 | $script:clicked = 0 |
| 83 | function Invoke-UpgradeUIButton($element) { $script:clicked++ } |
| 84 | $pending = New-UIElement 'Import and open' 'reasonix-prepare-restored-session' |
| 85 | $pendingRoot = New-UIElement '' '' $false @($pending) |
| 86 | $pending.Current.IsOffscreen = $true |
| 87 | Assert-True (-not (Invoke-PendingHistoricalSession $pendingRoot)) 'Hidden preparation actions must not be clicked.' |
| 88 | $pending.Current.IsOffscreen = $false |
| 89 | Assert-True (Invoke-PendingHistoricalSession $pendingRoot) 'Explicit preparation must invoke the actual pending-session action.' |
| 90 | Assert-True ($script:clicked -eq 1) 'Invoke only the visible action.' |
| 91 | $body = New-UIElement $marker |
| 92 | $transcript = New-UIElement '' 'reasonix-chat-transcript-upgrade-tab' $false @($body) |
| 93 | $sidebar = New-UIElement $marker 'sidebar-topic' |
| 94 | $root = New-UIElement '' '' $false @($sidebar) |
| 95 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'A matching sidebar title must not pass.' |
| 96 | $root.Children = @($sidebar, $transcript) |
| 97 | Assert-True (Test-VisibleUpgradeHistory $root $marker) 'Visible transcript assistant body should pass.' |
| 98 | $root.Children = @($sidebar, $transcript, $pending) |
| 99 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Prepared content must not retain the import action.' |
| 100 | Assert-True (Test-VisibleUpgradeHistory $root $marker -AllowPendingSource) 'Unprepared legacy history may show the import action beside its body.' |
| 101 | $root.Children = @($sidebar, $pending) |
| 102 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker -AllowPendingSource)) 'The import action alone is not visible history.' |
| 103 | $root.Children = @($sidebar, $transcript) |
| 104 | $failure = New-UIElement 'Failed to load conversation history. Previous content was kept when available — retry to try again.' |
| 105 | $transcript.Children = @($body, $failure) |
| 106 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Visible history with an obsolete failure notice must not pass.' |
| 107 | $transcript.Children = @($body) |
| 108 | $recovery = New-UIElement 'Loading history' 'reasonix-session-recovery-test' |
| 109 | $root.Children = @($sidebar, $transcript, $recovery) |
| 110 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'History behind an unresolved recovery banner must not pass.' |
| 111 | $recovery.Current.IsOffscreen = $true |
| 112 | Assert-True (Test-VisibleUpgradeHistory $root $marker) 'An offscreen recovery surface does not block the visible session.' |
| 113 | $root.Children = @($sidebar, $transcript) |
| 114 | $body.Current.IsOffscreen = $true |
| 115 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Offscreen history must not pass.' |
| 116 | $body.Current.IsOffscreen = $false |
| 117 | $transcript.Current.IsOffscreen = $true |
| 118 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Hidden transcript must not pass.' |
| 119 | $transcript.Current.IsOffscreen = $false |
| 120 | $body.Current.BoundingRectangle.Width = 0 |
| 121 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Zero-size history must not pass.' |
| 122 | $body.Current.BoundingRectangle.Width = 200 |
| 123 | $body.Current.Name = 'unrelated body' |
| 124 | Assert-True (-not (Test-VisibleUpgradeHistory $root $marker)) 'Matching text outside the transcript must not pass.' |
| 125 | |
| 126 | # Exercise the real wait owner with a virtual monotonic clock. Slow import |
| 127 | # and lazy body exposure are allowed without slowing this contract test. |
| 128 | $script:upgradeWaitNow = 0 |
| 129 | function Get-UpgradeUITimeMilliseconds { return $script:upgradeWaitNow } |
| 130 | function Wait-UpgradeUIPoll { $script:upgradeWaitNow += 250 } |
| 131 | $script:upgradeWaitRoot = $root |
| 132 | $slowBody = { |
| 133 | $script:upgradeWaitRoot.Children = if ($script:clicked -eq 0) { @($pending) } else { @($transcript) } |
| 134 | $body.Current.Name = if ($script:upgradeWaitNow -ge 30000) { $marker } else { 'body pending' } |
| 135 | return $script:upgradeWaitRoot |
| 136 | } |
| 137 | $script:clicked = 0 |
| 138 | $oldBudget = Wait-VisibleUpgradeHistory -ReadRoot $slowBody -Text $marker -PrepareHistoricalSession -TimeoutSeconds 20 |
| 139 | Assert-True (-not $oldBudget.Found) 'The old fixed budget rejects a correct slow import.' |
| 140 | $script:upgradeWaitNow = 0 |
| 141 | $script:clicked = 0 |
| 142 | $observed = Wait-VisibleUpgradeHistory -ReadRoot $slowBody -Text $marker -PrepareHistoricalSession |
| 143 | Assert-True ($observed.Found -and $observed.ElapsedMilliseconds -eq 30000) 'Functional acceptance must observe the completed slow import.' |
| 144 | Assert-True ($script:clicked -eq 1) 'Polling must never execute import twice.' |
| 145 | |
| 146 | $script:upgradeWaitNow = 0 |
| 147 | $atDeadline = Wait-VisibleUpgradeHistory -Text $marker -TimeoutSeconds 20 -ReadRoot { |
| 148 | $script:upgradeWaitRoot.Children = @($transcript) |
| 149 | $body.Current.Name = if ($script:upgradeWaitNow -ge 20000) { $marker } else { 'body pending' } |
| 150 | return $script:upgradeWaitRoot |
| 151 | } |
| 152 | Assert-True ($atDeadline.Found -and $atDeadline.ElapsedMilliseconds -eq 20000) 'The deadline must use a final fresh observation, not the previous empty sample.' |
| 153 | |
| 154 | $script:upgradeWaitNow = 0 |
| 155 | $body.Current.Name = 'never restored' |
| 156 | $missing = Wait-VisibleUpgradeHistory -ReadRoot { $script:upgradeWaitRoot } -Text $marker -TimeoutSeconds 1 |
| 157 | Assert-True (-not $missing.Found -and $missing.ElapsedMilliseconds -eq 1000) 'Missing history still fails within the bounded wait.' |
| 158 | $script:upgradeWaitNow = 0 |
| 159 | $script:clicked = 0 |
| 160 | $body.Current.Name = $marker |
| 161 | $script:upgradeWaitRoot.Children = @($transcript) |
| 162 | $alreadyReadable = Wait-VisibleUpgradeHistory -ReadRoot { $script:upgradeWaitRoot } -Text $marker -PrepareHistoricalSession -TimeoutSeconds 1 |
| 163 | Assert-True ($alreadyReadable.Found -and $alreadyReadable.Prepared -and $script:clicked -eq 0) 'Readable history must pass without forcing runtime activation.' |
| 164 | $script:upgradeWaitRoot.Children = @($transcript, $pending) |
| 165 | $unprepared = Wait-VisibleUpgradeHistory -ReadRoot { $script:upgradeWaitRoot } -Text $marker -PrepareHistoricalSession -TimeoutSeconds 1 |
| 166 | Assert-True (-not $unprepared.Found) 'A matching body cannot bypass a still-pending preparation action.' |
| 167 | $script:clicked = 0 |
| 168 | $legacyView = Wait-VisibleUpgradeHistory -ReadRoot { $script:upgradeWaitRoot } -Text $marker -TimeoutSeconds 1 |
| 169 | Assert-True ($legacyView.Found -and $script:clicked -eq 0) 'Without preparation the legacy body passes beside the import action and nothing is imported.' |
| 170 | Write-Host 'Windows upgrade orchestration and UI evidence contracts passed (mocked native boundaries).' |
| 171 | } finally { |
| 172 | Remove-Item -LiteralPath $testRoot -Recurse -Force -ErrorAction SilentlyContinue |
| 173 | } |
| 174 |