Files
pebrel/scripts/tests/test_update_handoff.ps1

174 lines
11 KiB
PowerShell

# Native helper contracts with small process fixtures. Full product upgrade and
# AI/WSL recovery acceptance must be run separately with actual product packages.
param([string]$OutputRoot)
$ErrorActionPreference = 'Stop'
$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
if (-not $OutputRoot) { $OutputRoot = Join-Path $root ('tmp\handoff-tests-' + [guid]::NewGuid()) }
$null = New-Item -ItemType Directory -Path $OutputRoot -Force
$fixture = Join-Path $PSScriptRoot 'fixtures\update_process.cs'
$compiler = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe'
$helper = Join-Path $root 'nebula_app\src\update_download\handoff.ps1'
$powershell = Join-Path $env:WINDIR 'System32\WindowsPowerShell\v1.0\powershell.exe'
$old = Join-Path $OutputRoot 'old.exe'
$candidate = Join-Path $OutputRoot 'candidate.exe'
$reinstall = Join-Path $OutputRoot 'reinstall.exe'
$installer = Join-Path $OutputRoot 'installer.exe'
& $compiler /nologo /target:exe "/out:$old" $fixture
if ($LASTEXITCODE) { throw 'Could not build old process fixture' }
& $compiler /nologo /target:exe /define:NEW_VERSION "/out:$candidate" $fixture
if ($LASTEXITCODE) { throw 'Could not build candidate fixture' }
& $compiler /nologo /target:exe /define:SAME_VERSION "/out:$reinstall" $fixture
if ($LASTEXITCODE) { throw 'Could not build same-version candidate fixture' }
& $compiler /nologo /target:exe /define:INSTALLER "/out:$installer" $fixture
if ($LASTEXITCODE) { throw 'Could not build installer fixture' }
function Assert([bool]$Condition, [string]$Message) { if (-not $Condition) { throw $Message } }
function Wait-File([string]$Path, [int]$Seconds = 12) {
$deadline = [DateTime]::UtcNow.AddSeconds($Seconds)
while (-not (Test-Path -LiteralPath $Path)) {
if ([DateTime]::UtcNow -ge $deadline) { throw "Timed out waiting for $Path" }
Start-Sleep -Milliseconds 50
}
}
function Write-Json([string]$Path, $Value) {
[System.IO.File]::WriteAllText($Path, ($Value | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false))
}
$results = @()
foreach ($scenario in @('cancel', 'success', 'checksum', 'creation-time', 'installer-failure', 'other-process', 'late-process', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
$directory = Join-Path $OutputRoot $scenario
$installation = Join-Path $directory 'installed app'
$transaction = Join-Path $directory 'transaction'
$config = Join-Path $directory 'config'
$null = New-Item -ItemType Directory -Path $installation, $transaction, $config -Force
$executable = Join-Path $installation 'pebrel.exe'
Copy-Item -LiteralPath $old -Destination $executable
$payload = if ($scenario -eq 'reinstall') { $reinstall } elseif ($scenario -eq 'repair-identical') { $old } else { $candidate }
Copy-Item -LiteralPath $payload -Destination (Join-Path $directory 'candidate.exe')
[System.IO.File]::WriteAllText((Join-Path $installation 'unins000.exe'), 'fixture marker')
$env:PEBREL_HANDOFF_FIXTURE = $directory
$env:PEBREL_HANDOFF_FAIL_INSTALL = if ($scenario -eq 'installer-failure') { '1' } else { '0' }
$env:PEBREL_HANDOFF_NOOP_INSTALL = if ($scenario -eq 'upgrade-noop') { '1' } else { '0' }
$parent = Start-Process -FilePath $executable -ArgumentList 'wait' -PassThru
$null = $parent.Handle
$runner = $null
$other = $null
$helperLock = $null
try {
if ($scenario -in @('helper-unlocked', 'helper-held')) {
$null = New-Item -ItemType Directory -Path (Join-Path $installation 'runtime') -Force
$helperPath = Join-Path $installation 'runtime\pebrel-hook.exe'
[System.IO.File]::WriteAllText($helperPath, 'old helper fixture')
$helperLock = [System.IO.FileStream]::new($helperPath, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
}
$plan = @{
schema = 1; transaction = $scenario; executable = $executable; installation = $installation
config_directory = $config; installer = $installer
bytes = (Get-Item -LiteralPath $installer).Length
sha256 = (Get-FileHash -LiteralPath $installer -Algorithm SHA256).Hash
version = '9.9.9'; original_version = '1.8.0'; guard_path = (Join-Path $directory 'install.nebula-lock')
participants = @(@{ pid = $parent.Id; created = $parent.StartTime.ToUniversalTime().ToFileTimeUtc().ToString() })
}
if ($scenario -eq 'checksum') { $plan.sha256 = '0' * 64 }
if ($scenario -in @('reinstall', 'repair-identical')) { $plan.version = '1.8.0' }
if ($scenario -eq 'creation-time') { $plan.participants[0].created = '1' }
if ($scenario -eq 'other-process') {
$other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru
}
$planPath = Join-Path $transaction 'plan.json'
Write-Json $planPath $plan
$runner = Start-Process -FilePath $powershell -ArgumentList @(
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File',
('"' + $helper + '"'), '-PlanPath', ('"' + $planPath + '"')) -PassThru
$resultPath = Join-Path $transaction 'result.json'
if ($scenario -in @('checksum', 'creation-time', 'other-process')) {
Wait-File $resultPath
Assert (-not $parent.HasExited) 'Rejected plan must leave the original process open'
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Rejected plan started setup'
} else {
Wait-File (Join-Path $transaction 'ready.json')
Assert (-not $parent.HasExited) 'Readiness must not stop the original process'
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran before commit'
$locked = $false
try {
$probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::Open,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
} catch [System.IO.IOException] { $locked = $true }
Assert $locked 'Ready helper did not own the installation lock'
if ($scenario -eq 'late-process') {
$other = Start-Process -FilePath $executable -ArgumentList 'wait-other' -PassThru
}
if ($scenario -eq 'cancel') {
Write-Json (Join-Path $transaction 'cancel.json') @{}
Wait-File $resultPath
Assert (-not $parent.HasExited) 'Cancel stopped the original process'
} else {
Write-Json (Join-Path $transaction 'workspace.json') @(@{ tabs = @(@{ cwd = 'fixture' }) })
Write-Json (Join-Path $transaction 'commit.json') @{ transaction = $scenario }
Start-Sleep -Milliseconds 300
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran while the old process was alive'
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
Assert ($parent.WaitForExit(5000)) 'Fixture parent did not exit'
if ($scenario -eq 'helper-unlocked') {
Start-Sleep -Milliseconds 350
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup started with the helper still locked'
$helperLock.Dispose()
$helperLock = $null
}
Wait-File $resultPath
if ($scenario -in @('success', 'installer-failure', 'reinstall', 'repair-identical', 'upgrade-noop', 'helper-unlocked', 'helper-held')) {
Wait-File (Join-Path $directory 'new-launch')
$launch = [System.IO.File]::ReadAllLines((Join-Path $directory 'new-launch'))
Assert ($launch[0] -eq $executable) 'Relaunch selected another installation'
Assert ($launch[1] -eq $planPath) 'Restore ticket was not passed to the new process'
Assert ($launch[2] -eq $config) 'Relaunch lost the configuration directory'
}
Assert (Test-Path (Join-Path $transaction 'workspace.json')) 'Update discarded the original snapshot'
}
}
Assert ($runner.WaitForExit(5000)) 'Helper did not finish'
$result = Get-Content -LiteralPath $resultPath -Raw -Encoding UTF8 | ConvertFrom-Json
Assert ($result.success -eq ($scenario -in @('success', 'reinstall', 'repair-identical', 'helper-unlocked'))) 'Unexpected helper outcome'
if ($result.success) {
$expected = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash
Assert ($result.executable_sha256 -eq $expected) 'Installed binary differs from candidate'
Assert (Test-Path -LiteralPath (Join-Path $installation 'runtime\pebrel-hook.exe')) 'Successful setup did not repair the helper'
Assert ([System.IO.File]::ReadAllText((Join-Path $installation 'runtime\pebrel-hook.exe')) -eq 'repaired helper fixture') 'Helper still contains the original payload'
}
if ($scenario -in @('other-process', 'late-process')) {
Assert (-not $other.HasExited) 'Update stopped an unprepared process'
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'Setup ran with an unprepared process'
}
if ($scenario -in @('installer-failure', 'upgrade-noop', 'helper-held')) {
Assert $result.recovered_original 'Untouched old executable was not recovered'
}
if ($scenario -eq 'helper-held') {
Assert (-not (Test-Path (Join-Path $directory 'installer-started'))) 'A blocked helper allowed a partial installation'
Assert ($result.error -match 'pebrel-hook.exe') 'The blocked file is missing from the failure details'
}
$probe = [System.IO.FileStream]::new($plan.guard_path, [System.IO.FileMode]::OpenOrCreate,
[System.IO.FileAccess]::ReadWrite, [System.IO.FileShare]::None)
$probe.Dispose()
$results += @{ scenario = $scenario; passed = $true }
Write-Output "$scenario : passed"
} finally {
if ($helperLock) { $helperLock.Dispose() }
if ($runner -and -not $runner.HasExited) { $runner.Kill(); $runner.WaitForExit() }
if (-not $parent.HasExited) {
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-parent'), 'exit')
if (-not $parent.WaitForExit(5000)) { $parent.Kill(); $parent.WaitForExit() }
}
if ($other) {
[System.IO.File]::WriteAllText((Join-Path $directory 'exit-other'), 'exit')
if (-not $other.WaitForExit(5000)) { $other.Kill(); $other.WaitForExit() }
$other.Dispose()
}
if ($runner) { $runner.Dispose() }
$parent.Dispose()
}
}
Write-Json (Join-Path $OutputRoot 'results.json') $results