[CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Installer, [string]$BuildPython = 'python', [string]$DiagnosticsPath = '' ) $ErrorActionPreference = 'Stop' $installerPath = (Resolve-Path -LiteralPath $Installer).Path $buildPythonPath = (Get-Command $BuildPython -ErrorAction Stop).Source $testRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('arx-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) $installPath = Join-Path $testRoot 'AskRex' $localAppData = Join-Path $testRoot 'LocalAppData' $appData = Join-Path $testRoot 'AppData' $runtimeRoot = Join-Path $appData 'rex-gui' $lifecycleRuntimeRoot = Join-Path $testRoot 'Lifecycle Runtime With Spaces' $backgroundTaskName = 'AskRex Background Runtime' $smokeOutput = Join-Path $testRoot 'smoke.json' $script:smokePhase = 'initializing' function Write-SmokeDiagnostics( [string]$Status, [string]$Message = '' ) { if (-not $DiagnosticsPath) { return } $diagnosticDirectory = Split-Path -Parent $DiagnosticsPath if ($diagnosticDirectory) { New-Item -ItemType Directory -Force -Path $diagnosticDirectory | Out-Null } $payload = [ordered]@{ status = $Status phase = $script:smokePhase message = $Message timestamp_utc = [DateTime]::UtcNow.ToString('o') } | ConvertTo-Json $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($DiagnosticsPath, $payload, $utf8NoBom) } function Set-SmokePhase([string]$Phase) { $script:smokePhase = $Phase Write-Host "[artifact-smoke] $Phase" Write-SmokeDiagnostics 'running' } function Stop-ProcessTree([System.Diagnostics.Process]$Process) { if ($null -eq $Process) { return } try { if (-not $Process.HasExited) { $taskkill = Join-Path $env:SystemRoot 'System32\taskkill.exe' & $taskkill /PID $Process.Id /T /F 2>$null | Out-Null $Process.WaitForExit(10000) | Out-Null } } catch { Write-Verbose "Process tree cleanup skipped for PID $($Process.Id): $($_.Exception.Message)" } } function Stop-InstalledProcesses([string]$RootPath) { $normalizedRoot = [System.IO.Path]::GetFullPath($RootPath).TrimEnd('\') + '\' Get-Process -ErrorAction SilentlyContinue | ForEach-Object { try { $processPath = $_.Path if ($processPath -and $processPath.StartsWith( $normalizedRoot, [System.StringComparison]::OrdinalIgnoreCase )) { Stop-ProcessTree $_ } } catch { # Some system processes do not expose Path. They are unrelated to this install. } } } function Get-BackgroundStartupTaskState { try { $service = New-Object -ComObject 'Schedule.Service' $service.Connect() $folder = $service.GetFolder('\') $null = $folder.GetTask($backgroundTaskName) return 'present' } catch { if (($_.Exception.HResult -band 0xFFFF) -eq 2) { return 'absent' } throw 'Could not query the AskRex background startup task.' } } function Assert-BackgroundStartupTaskPresent { if ((Get-BackgroundStartupTaskState) -ne 'present') { throw 'Installed artifact did not register the AskRex background ONLOGON task.' } } function Assert-BackgroundStartupTaskAbsent { if ((Get-BackgroundStartupTaskState) -ne 'absent') { throw 'AskRex background startup task survived uninstall.' } } function Remove-SmokeBackgroundStartupTask { $schtasks = Join-Path $env:SystemRoot 'System32\schtasks.exe' try { & $schtasks /End /TN $backgroundTaskName *> $null } catch { Write-Verbose "Background task end cleanup skipped: $($_.Exception.Message)" } try { & $schtasks /Delete /TN $backgroundTaskName /F *> $null } catch { Write-Verbose "Background task delete cleanup skipped: $($_.Exception.Message)" } $global:LASTEXITCODE = 0 } function Invoke-Installer( [string[]]$Arguments, [int]$TimeoutSeconds = 600 ) { $process = Start-Process -FilePath $installerPath -ArgumentList $Arguments -PassThru if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { Stop-ProcessTree $process throw "Installer timed out after $TimeoutSeconds seconds." } if ($process.ExitCode -ne 0) { throw "Installer exited with code $($process.ExitCode)" } } function Invoke-Uninstaller( [string]$UninstallerPath, [string]$InstallRoot, [int]$TimeoutSeconds = 300 ) { if (-not (Test-Path -LiteralPath $UninstallerPath -PathType Leaf)) { throw "Uninstaller is missing: $UninstallerPath" } # Mirror electron-builder's own upgrade path: copy the uninstaller outside # the installation directory, then run that copy with _?= so WaitForExit # observes the process that actually removes the installed files. Running # the installed executable with _?= directly can fail because it is still # located inside the directory it is deleting. $uninstallCopyRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('askrex-uninstall-' + [guid]::NewGuid().ToString('N')) $uninstallCopy = Join-Path $uninstallCopyRoot 'uninstaller.exe' New-Item -ItemType Directory -Force -Path $uninstallCopyRoot | Out-Null Copy-Item -LiteralPath $UninstallerPath -Destination $uninstallCopy -Force try { $process = Start-Process -FilePath $uninstallCopy -ArgumentList @('/S', '/currentuser', "_?=$InstallRoot") -PassThru if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { Stop-ProcessTree $process throw "Uninstaller timed out after $TimeoutSeconds seconds." } if ($process.ExitCode -ne 0) { throw "Uninstaller exited with code $($process.ExitCode)" } } finally { if (Test-Path -LiteralPath $uninstallCopyRoot) { Remove-Item -LiteralPath $uninstallCopyRoot -Recurse -Force -ErrorAction SilentlyContinue } } } function Assert-Uninstalled([string]$ApplicationPath) { $deadline = [DateTime]::UtcNow.AddSeconds(30) while ((Test-Path -LiteralPath $ApplicationPath) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 250 } if (Test-Path -LiteralPath $ApplicationPath) { throw 'Uninstall left the application executable behind.' } } function Remove-SmokeTestRoot([string]$RootPath) { if (-not (Test-Path -LiteralPath $RootPath)) { return } $resolvedRoot = (Resolve-Path -LiteralPath $RootPath).Path $tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()) if (-not $resolvedRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase)) { throw "Refusing to clean test path outside the temp directory: $resolvedRoot" } for ($attempt = 1; $attempt -le 10; $attempt++) { try { Remove-Item -LiteralPath $resolvedRoot -Recurse -Force -ErrorAction Stop return } catch { if ($attempt -eq 10) { Write-Warning "Could not fully remove temporary smoke directory after 10 attempts: $resolvedRoot. $($_.Exception.Message)" return } Start-Sleep -Seconds 2 } } } function Invoke-IdentityBridge( [string]$PythonExe, [string]$BridgeScript, [string]$Payload ) { # Windows PowerShell 5 can transcode native-command pipeline input and its # ProcessStartInfo lacks the modern encoding properties. Use exact UTF-8 file-backed # stdin redirection so the packaged bridge receives byte-for-byte valid JSON. $callRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('askrex-identity-' + [guid]::NewGuid()) $stdinPath = Join-Path $callRoot 'stdin.json' $stdoutPath = Join-Path $callRoot 'stdout.txt' $stderrPath = Join-Path $callRoot 'stderr.txt' New-Item -ItemType Directory -Force -Path $callRoot | Out-Null try { $utf8NoBom = New-Object System.Text.UTF8Encoding($false) [System.IO.File]::WriteAllText($stdinPath, $Payload, $utf8NoBom) $arguments = "-I `"$BridgeScript`"" $startProcessArguments = @{ FilePath = $PythonExe ArgumentList = $arguments RedirectStandardInput = $stdinPath RedirectStandardOutput = $stdoutPath RedirectStandardError = $stderrPath Wait = $true PassThru = $true NoNewWindow = $true } $process = Start-Process @startProcessArguments return [pscustomobject]@{ ExitCode = $process.ExitCode Stdout = if (Test-Path -LiteralPath $stdoutPath) { Get-Content -LiteralPath $stdoutPath -Raw } else { '' } Stderr = if (Test-Path -LiteralPath $stderrPath) { Get-Content -LiteralPath $stderrPath -Raw } else { '' } } } finally { if (Test-Path -LiteralPath $callRoot) { Remove-Item -LiteralPath $callRoot -Recurse -Force } } } function Invoke-ElectronBackgroundSurvivalSmoke( [string]$RuntimeRoot, [string]$RuntimePython ) { # The packaged Electron process has exited. Its detached supervisor must # remain independently alive and publish fresh, content-free health. $statusArgs = @('-I', '-m', 'rex.background.cli', 'status', '--runtime-root', $RuntimeRoot) $statusOutput = & $RuntimePython @statusArgs 2>$null $statusExit = $LASTEXITCODE if ($statusExit -ne 0 -or -not $statusOutput) { throw 'Electron background survival smoke: detached supervisor status is unavailable after GUI exit.' } $status = ($statusOutput | Select-Object -Last 1) | ConvertFrom-Json $supervisorPid = [int]$status.supervisor_pid if ($supervisorPid -le 0 -or -not (Get-Process -Id $supervisorPid -ErrorAction SilentlyContinue)) { throw 'Electron background survival smoke: detached supervisor is not alive after GUI exit.' } Write-Host 'Electron background survival smoke passed (detached supervisor survived GUI exit and remains live for uninstall verification).' return $supervisorPid } function Invoke-BackgroundLifecycleSmoke( [string]$Resources, [string]$RuntimeRoot, [string]$RuntimePython ) { # Deterministic lifecycle proof uses the real installed RuntimeSupervisor # from isolated managed Python, with self-contained child fakes copied into # the temporary runtime root. No product test hook or source-tree import is used. $runtimePythonW = Join-Path $Resources 'python\pythonw.exe' if (-not (Test-Path -LiteralPath $runtimePythonW -PathType Leaf)) { throw "Installed artifact is missing: $runtimePythonW" } $harnessSource = Join-Path $PSScriptRoot 'background_lifecycle_harness.py' $fakeChildSource = Join-Path $PSScriptRoot 'background_lifecycle_fake_child.py' foreach ($required in @($harnessSource, $fakeChildSource)) { if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Lifecycle smoke fixture is missing: $required" } } New-Item -ItemType Directory -Force -Path $RuntimeRoot | Out-Null Remove-Item -Recurse -Force (Join-Path $RuntimeRoot 'background') -ErrorAction SilentlyContinue $harnessScript = Join-Path $RuntimeRoot 'background_lifecycle_harness.py' $fakeChildScript = Join-Path $RuntimeRoot 'background_lifecycle_fake_child.py' Copy-Item -LiteralPath $harnessSource -Destination $harnessScript -Force Copy-Item -LiteralPath $fakeChildSource -Destination $fakeChildScript -Force $importProbe = & $RuntimePython -I -c "import pathlib,sys,rex.background; p=pathlib.Path(rex.background.__file__).resolve(); r=pathlib.Path(sys.executable).resolve().parent/'Lib'/'site-packages'; print(p); raise SystemExit(0 if r in p.parents else 9)" 2>$null if ($LASTEXITCODE -ne 0) { throw "Background lifecycle smoke: managed rex.background import escaped installed resources: $importProbe" } $statusArgs = @('-I', '-m', 'rex.background.cli', 'status', '--runtime-root', $RuntimeRoot) $harnessArgs = @('-I', $harnessScript, $RuntimeRoot, $fakeChildScript) $harnessArgumentString = "-I `"$harnessScript`" `"$RuntimeRoot`" `"$fakeChildScript`"" $supervisorProcess = $null try { $supervisorProcess = Start-Process -FilePath $runtimePythonW -ArgumentList $harnessArgumentString -PassThru -WindowStyle Hidden $statusReady = $false $deadline = [DateTime]::UtcNow.AddSeconds(30) while ([DateTime]::UtcNow -lt $deadline) { $statusOutput = & $RuntimePython @statusArgs 2>$null $statusExit = $LASTEXITCODE if ($statusExit -eq 0 -and $statusOutput) { $status = ($statusOutput | Select-Object -Last 1) | ConvertFrom-Json if ($status.core.state -eq 'ready' -and $status.voice_agent.state -eq 'ready') { $statusReady = $true break } } Start-Sleep -Milliseconds 500 } if (-not $statusReady) { throw 'Background lifecycle smoke: fake-child supervisor never reached ready status.' } & $RuntimePython @harnessArgs *> $null if ($LASTEXITCODE -ne 2) { throw "Background lifecycle smoke: duplicate supervisor start was not rejected (exit $LASTEXITCODE)." } & $RuntimePython @statusArgs *> $null if ($LASTEXITCODE -ne 0) { throw 'Background lifecycle smoke: status became unreadable after duplicate-start probe.' } if ($supervisorProcess.HasExited) { throw 'Background lifecycle smoke: detached supervisor exited unexpectedly.' } & $RuntimePython -I -m rex.background.cli stop --runtime-root $RuntimeRoot *> $null if ($LASTEXITCODE -ne 0) { throw 'Background lifecycle smoke: stop request was rejected.' } if (-not $supervisorProcess.WaitForExit(15000)) { throw 'Background lifecycle smoke: supervisor did not exit after an orderly stop request.' } & $RuntimePython @statusArgs *> $null if ($LASTEXITCODE -eq 0) { throw 'Background lifecycle smoke: status still reports live after orderly stop.' } } finally { if ($null -ne $supervisorProcess -and -not $supervisorProcess.HasExited) { Stop-ProcessTree $supervisorProcess } } Write-Host 'Background lifecycle smoke passed (managed import, duplicate prevention, ready status, detached liveness, orderly stop) using packaged Windows artifact / deterministic child fakes.' } try { Write-SmokeDiagnostics 'running' New-Item -ItemType Directory -Force -Path $testRoot, $localAppData, $appData, $runtimeRoot | Out-Null Set-SmokePhase 'initial-install' Invoke-Installer @('/S', "/D=$installPath") $appExe = Join-Path $installPath 'AskRex.exe' $uninstaller = Join-Path $installPath 'Uninstall AskRex.exe' $resources = Join-Path $installPath 'resources' $runtimePython = Join-Path $resources 'python\python.exe' foreach ($required in @($appExe, $uninstaller, $runtimePython)) { if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Installed artifact is missing: $required" } } Set-SmokePhase 'installed-resource-verification' & $buildPythonPath (Join-Path $PSScriptRoot 'verify_electron_package_contents.py') $resources if ($LASTEXITCODE -ne 0) { throw 'Installed resource verification failed.' } $env:LOCALAPPDATA = $localAppData $env:APPDATA = $appData $env:ASKREX_RUNTIME_DIR = $runtimeRoot & $runtimePython -I -c "from rex.auth import create_user; from rex.identity import set_session_user; from rex.permissions import bootstrap_admin_if_first_user; u=create_user('artifact-smoke-user','artifact-smoke-password'); bootstrap_admin_if_first_user(str(u['id'])); set_session_user('artifact-ci-user')" if ($LASTEXITCODE -ne 0) { throw 'Managed runtime could not bootstrap setup state and establish the smoke identity.' } Set-SmokePhase 'identity-bridge' $identityPayload = '{"action":"resolve_electron_session"}' $identityBridge = Join-Path $resources 'bridge\rex_identity_bridge.py' $identityResult = $null for ($attempt = 1; $attempt -le 3; $attempt++) { $identityResult = Invoke-IdentityBridge $runtimePython $identityBridge $identityPayload if ($identityResult.ExitCode -eq 0) { break } if ($attempt -lt 3) { Start-Sleep -Seconds 2 } } if ($null -eq $identityResult -or $identityResult.ExitCode -ne 0) { $exitCode = if ($null -eq $identityResult) { -1 } else { $identityResult.ExitCode } $diagnostic = if ($null -eq $identityResult) { 'identity bridge did not start' } else { $parts = @($identityResult.Stdout.Trim(), $identityResult.Stderr.Trim()) | Where-Object { $_ } $parts -join ' ' } throw "Read-only identity bridge action failed after 3 attempts (exit $exitCode): $diagnostic" } $identityJson = $identityResult.Stdout -split '\r?\n' | Where-Object { $_.TrimStart().StartsWith('{') } | Select-Object -Last 1 if (-not $identityJson) { throw "Read-only identity bridge returned no JSON: $($identityResult.Stdout) $($identityResult.Stderr)" } $identity = $identityJson | ConvertFrom-Json if (-not $identity.ok -or $identity.user_id -ne 'artifact-ci-user') { throw 'Read-only identity bridge returned an unexpected result.' } Set-SmokePhase 'electron-ipc-smoke' $env:ASKREX_ARTIFACT_SMOKE = '1' $env:ASKREX_ARTIFACT_SMOKE_RUNTIME_ROOT = $runtimeRoot $env:ASKREX_ARTIFACT_SMOKE_OUTPUT = $smokeOutput $env:PATH = Join-Path $env:SystemRoot 'System32' $app = Start-Process -FilePath $appExe -PassThru $deadline = [DateTime]::UtcNow.AddSeconds(90) while (-not (Test-Path -LiteralPath $smokeOutput) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 500 } if (-not (Test-Path -LiteralPath $smokeOutput)) { Stop-ProcessTree $app throw 'Installed app did not produce the IPC smoke result within 90 seconds.' } $result = Get-Content -LiteralPath $smokeOutput -Raw | ConvertFrom-Json if (-not $result.ok -or -not $result.typed_ipc -or $result.chat -ne 'AskRex installed artifact chat verified' -or $result.memories_count -lt 0 -or -not $result.openclaw_settings -or -not $result.openclaw_settings_read_write -or -not $result.settings_sections) { throw "Installed app smoke failed: $(Get-Content -LiteralPath $smokeOutput -Raw)" } Assert-BackgroundStartupTaskPresent if (-not $app.WaitForExit(15000)) { Stop-ProcessTree $app throw 'Electron process did not exit before background survival verification.' } Set-SmokePhase 'electron-background-survival' $survivingSupervisorPid = Invoke-ElectronBackgroundSurvivalSmoke -RuntimeRoot $runtimeRoot -RuntimePython $runtimePython Set-SmokePhase 'background-lifecycle-smoke' Invoke-BackgroundLifecycleSmoke -Resources $resources -RuntimeRoot $lifecycleRuntimeRoot -RuntimePython $runtimePython Set-SmokePhase 'first-uninstall' Invoke-Uninstaller $uninstaller $installPath Assert-Uninstalled $appExe Assert-BackgroundStartupTaskAbsent $deadline = [DateTime]::UtcNow.AddSeconds(5) while ([DateTime]::UtcNow -lt $deadline -and (Get-Process -Id $survivingSupervisorPid -ErrorAction SilentlyContinue)) { Start-Sleep -Milliseconds 250 } if (Get-Process -Id $survivingSupervisorPid -ErrorAction SilentlyContinue) { throw 'Electron background supervisor survived uninstall.' } Set-SmokePhase 'reinstall' Invoke-Installer @('/S', "/D=$installPath") foreach ($required in @($appExe, $uninstaller, $runtimePython)) { if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { throw "Reinstalled artifact is missing: $required" } } & $buildPythonPath (Join-Path $PSScriptRoot 'verify_electron_package_contents.py') $resources if ($LASTEXITCODE -ne 0) { throw 'Reinstalled resource verification failed.' } Set-SmokePhase 'final-uninstall' Stop-InstalledProcesses $installPath Invoke-Uninstaller $uninstaller $installPath Assert-Uninstalled $appExe Set-SmokePhase 'complete' Write-SmokeDiagnostics 'success' 'Installed artifact passed all smoke phases.' Write-Host 'Installed AskRex artifact smoke passed (IPC, managed bridge, chat, uninstall, reinstall, final uninstall).' } catch { Write-SmokeDiagnostics 'failure' $_.Exception.Message throw } finally { Remove-SmokeBackgroundStartupTask Remove-Item Env:ASKREX_ARTIFACT_SMOKE -ErrorAction SilentlyContinue Remove-Item Env:ASKREX_ARTIFACT_SMOKE_OUTPUT -ErrorAction SilentlyContinue Remove-Item Env:ASKREX_ARTIFACT_SMOKE_RUNTIME_ROOT -ErrorAction SilentlyContinue Remove-Item Env:ASKREX_RUNTIME_DIR -ErrorAction SilentlyContinue if (Test-Path -LiteralPath $installPath) { Stop-InstalledProcesses $installPath } Remove-SmokeTestRoot $testRoot }