Ctrl K
ComunidadGuías…

scripts/test_installed_electron_artifact.ps1

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
[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
}