MSIX.RuntimeTest.ps1

# =============================================================================
# Runtime deployment test loop (deploy -> launch -> probe -> verdict)
# -----------------------------------------------------------------------------
# Test-MsixDeployment installs a signed package into a clean Hyper-V VM via
# PowerShell Direct (no VM networking needed), launches it through the shell,
# probes for liveness/crashes, and returns a verdict object shaped like the
# other Test-Msix* results (bottom-line boolean + reasons + artifacts).
#
# The VM interaction is funnelled through _MsixInvokeInVM / _MsixCopyToVM so
# the orchestration is unit-testable via mocks; the real Hyper-V path only runs
# on a host with the module and a prepared golden VM.
#
# On failure the collected ProcMon/event artifacts feed straight back into
# Get-MsixProcMonFailure -> Get-MsixIsolationAdvice -> Invoke-MsixAutoFixFromAnalysis,
# closing the loop test -> analyze -> autofix -> retest.
# =============================================================================

function _MsixInvokeInVM {
    # Thin seam over PowerShell Direct so Test-MsixDeployment is mockable.
    # Returns whatever the scriptblock returns.
    param(
        [Parameter(Mandatory)] [string]$VMName,
        [Parameter(Mandatory)] [pscredential]$Credential,
        [Parameter(Mandatory)] [scriptblock]$ScriptBlock,
        [object[]]$ArgumentList
    )
    Invoke-Command -VMName $VMName -Credential $Credential -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop
}

function _MsixCopyToVM {
    # Copies a host file into the VM over the PowerShell Direct session.
    param(
        [Parameter(Mandatory)] [string]$VMName,
        [Parameter(Mandatory)] [pscredential]$Credential,
        [Parameter(Mandatory)] [string]$Path,
        [Parameter(Mandatory)] [string]$Destination
    )
    $session = New-PSSession -VMName $VMName -Credential $Credential -ErrorAction Stop
    try {
        Copy-Item -ToSession $session -Path $Path -Destination $Destination -Force -ErrorAction Stop
    } finally {
        Remove-PSSession -Session $session -ErrorAction SilentlyContinue
    }
}

function _MsixRestoreVMCheckpoint {
    param([Parameter(Mandatory)][string]$VMName, [Parameter(Mandatory)][string]$Checkpoint)
    Restore-VMCheckpoint -VMName $VMName -Name $Checkpoint -Confirm:$false -ErrorAction Stop
    Start-VM -Name $VMName -ErrorAction SilentlyContinue
}


function Test-MsixDeployment {
    <#
    .SYNOPSIS
        Installs, launches and probes a signed .msix inside a clean Hyper-V VM
        and returns a pass/fail verdict with reasons and diagnostic artifacts.
 
    .DESCRIPTION
        The automated runtime tier that complements static analysis:
 
          1. (optional) revert the VM to a clean checkpoint
          2. copy + trust the signing cert, then Add-AppxPackage the .msix
          3. launch via shell:AppsFolder\<PFN>!<AppId>
          4. probe: process alive after -SettleSeconds, no WER crash entry,
             no AppXDeployment-Server error events, (optional) a top-level
             window appeared
          5. return a verdict; on failure, pull the relevant event-log entries
             as artifacts so they can feed Invoke-MsixAutoFixFromAnalysis.
 
        VM interaction uses PowerShell Direct (Credential over the hypervisor
        bus — no VM network required). The VM must be a prepared golden image
        with Developer Mode / sideloading enabled and the module's runtime
        expectations met; see TEST-PLAN Scenario 14.
 
    .PARAMETER PackagePath
        The signed .msix to deploy (must be signed by a cert the VM will trust,
        or pass -CertPath to install one first).
 
    .PARAMETER VMName
        Name of the Hyper-V VM to deploy into.
 
    .PARAMETER Credential
        Local admin credential for the VM (PowerShell Direct auth).
 
    .PARAMETER CertPath
        Optional .cer to install into the VM's LocalMachine\TrustedPeople +
        Root before deployment (self-signed dev flows).
 
    .PARAMETER Checkpoint
        Optional VM checkpoint name to restore to before the run (clean slate).
 
    .PARAMETER AppId
        Application Id to launch. Default: the first Application in the manifest.
 
    .PARAMETER SettleSeconds
        Seconds to wait after launch before probing liveness. Default 10.
 
    .PARAMETER RequireWindow
        Also assert that the app created a top-level visible window (uses the
        VM's UI automation). Off by default (many valid apps are trayless).
 
    .PARAMETER KeepInstalled
        Do not remove the package / revert after the run (for manual inspection).
 
    .EXAMPLE
        $cred = Get-Credential
        Test-MsixDeployment -PackagePath .\app.msix -VMName 'Win11-24H2' `
            -Credential $cred -CertPath .\app.cer -Checkpoint 'clean'
 
    .OUTPUTS
        [pscustomobject] MSIX.DeploymentTestResult with Passed, PackageFullName,
        Reasons, Installed, Launched, ProcessAlive, WindowAppeared,
        CrashDetected, EventLogArtifacts.
 
    .LINK
        TEST-PLAN.md Scenario 14
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'phase',
        Justification = 'Phase marker consumed by the mockable _MsixInvokeInVM seam to discriminate calls, not by the remote scriptblocks themselves.')]
    param(
        [Parameter(Mandatory)] [string]$PackagePath,
        [Parameter(Mandatory)] [string]$VMName,
        [Parameter(Mandatory)] [pscredential]$Credential,
        [string]$CertPath,
        # Modification packages (#131) installed AFTER the main package so the
        # layered content/settings are part of the probed run.
        [string[]]$ModificationPackagePaths,
        [string]$Checkpoint,
        [ValidatePattern('^[A-Za-z_][A-Za-z0-9_.-]*$')]
        [string]$AppId,
        [ValidateRange(0, 600)]
        [int]$SettleSeconds = 10,
        [switch]$RequireWindow,
        [switch]$KeepInstalled
    )

    # Read identity on the host (avoids a round-trip and lets us build the AUMID).
    [xml]$manifest = Get-MsixManifest -Path $PackagePath
    $identityName = $manifest.Package.Identity.Name
    $apps = @($manifest.Package.Applications.Application)
    if ($AppId) {
        $app = $apps | Where-Object { $_.GetAttribute('Id') -eq $AppId } | Select-Object -First 1
        if (-not $app) { throw "Application '$AppId' not found in the manifest." }
    } else {
        $app = $apps | Select-Object -First 1
        if (-not $app) { throw 'No <Application> element found in the manifest.' }
    }
    $launchAppId = $app.GetAttribute('Id')

    $reasons = [System.Collections.Generic.List[string]]::new()
    $result = [ordered]@{
        PSTypeName        = 'MSIX.DeploymentTestResult'
        PackagePath       = $PackagePath
        VMName            = $VMName
        PackageFullName   = $null
        Installed         = $false
        ModificationsInstalled = 0
        Launched          = $false
        ProcessAlive      = $false
        WindowAppeared    = $null
        CrashDetected     = $false
        EventLogArtifacts = @()
        Reasons           = @()
        Passed            = $false
    }

    if ($Checkpoint) {
        Write-MsixLog -Level Info -Message "Restoring VM '$VMName' to checkpoint '$Checkpoint'."
        _MsixRestoreVMCheckpoint -VMName $VMName -Checkpoint $Checkpoint
    }

    # Stage the package (and cert, and modification packages) into the VM.
    $vmPkg = "C:\Windows\Temp\$([IO.Path]::GetFileName($PackagePath))"
    _MsixCopyToVM -VMName $VMName -Credential $Credential -Path $PackagePath -Destination $vmPkg
    $vmCert = $null
    if ($CertPath) {
        $vmCert = "C:\Windows\Temp\$([IO.Path]::GetFileName($CertPath))"
        _MsixCopyToVM -VMName $VMName -Credential $Credential -Path $CertPath -Destination $vmCert
    }
    $vmMods = @()
    foreach ($mod in @($ModificationPackagePaths | Where-Object { $_ })) {
        $vmMod = "C:\Windows\Temp\$([IO.Path]::GetFileName($mod))"
        _MsixCopyToVM -VMName $VMName -Credential $Credential -Path $mod -Destination $vmMod
        $vmMods += $vmMod
    }

    # Install (trust cert first if supplied), then any modification packages,
    # and capture the PackageFullName. The first ArgumentList element is a
    # phase marker so the mockable seam can discriminate the calls.
    $install = _MsixInvokeInVM -VMName $VMName -Credential $Credential -ArgumentList @('install', $vmPkg, $vmCert, $identityName, $vmMods) -ScriptBlock {
        param($phase, $pkgPath, $certPath, $idName, $modPaths)
        $r = [ordered]@{ Installed = $false; PackageFullName = $null; ModificationsInstalled = 0; Error = $null }
        try {
            if ($certPath) {
                Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\LocalMachine\TrustedPeople | Out-Null
                Import-Certificate -FilePath $certPath -CertStoreLocation Cert:\LocalMachine\Root | Out-Null
            }
            Add-AppxPackage -Path $pkgPath -ErrorAction Stop
            $pkg = Get-AppxPackage -Name $idName | Select-Object -First 1
            $r.PackageFullName = $pkg.PackageFullName
            $r.Installed = [bool]$pkg
            foreach ($modPath in @($modPaths | Where-Object { $_ })) {
                Add-AppxPackage -Path $modPath -ErrorAction Stop
                $r.ModificationsInstalled++
            }
        } catch {
            $r.Error = $_.Exception.Message
        }
        [pscustomobject]$r
    }

    $result.Installed = [bool]$install.Installed
    $result.ModificationsInstalled = [int]$install.ModificationsInstalled
    $result.PackageFullName = $install.PackageFullName
    if (-not $install.Installed) {
        $null = $reasons.Add("Add-AppxPackage failed: $($install.Error)")
        $result.Reasons = @($reasons)
        return [pscustomobject]$result
    }

    # Launch via the AppsFolder AUMID and probe liveness inside the VM.
    $probe = _MsixInvokeInVM -VMName $VMName -Credential $Credential -ArgumentList @('probe', $install.PackageFullName, $launchAppId, $SettleSeconds, [bool]$RequireWindow) -ScriptBlock {
        param($phase, $pfn, $appId, $settle, $requireWindow)
        $r = [ordered]@{ Launched = $false; ProcessAlive = $false; WindowAppeared = $null; CrashDetected = $false; Events = @(); Error = $null }
        try {
            $family = ($pfn -split '_')[0] + '_' + ($pfn -split '_')[-1]
            $aumid = "$family!$appId"
            $before = @(Get-Process -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Id)
            Start-Process "shell:AppsFolder\$aumid" -ErrorAction Stop
            $r.Launched = $true
            Start-Sleep -Seconds $settle

            $after = @(Get-Process -ErrorAction SilentlyContinue)
            $new = @($after | Where-Object { $_.Id -notin $before -and $_.Path -and $_.Path -like '*\WindowsApps\*' })
            $r.ProcessAlive = ($new.Count -gt 0)

            if ($requireWindow) {
                $r.WindowAppeared = [bool](@($new | Where-Object { $_.MainWindowHandle -ne 0 }).Count -gt 0)
            }

            # WER + AppXDeployment errors in the last few minutes.
            $since = (Get-Date).AddMinutes(-5)
            $wer = @(Get-WinEvent -FilterHashtable @{ LogName = 'Application'; ProviderName = 'Windows Error Reporting'; StartTime = $since } -ErrorAction SilentlyContinue)
            $appx = @(Get-WinEvent -FilterHashtable @{ LogName = 'Microsoft-Windows-AppXDeploymentServer/Operational'; Level = 2; StartTime = $since } -ErrorAction SilentlyContinue)
            $r.CrashDetected = ($wer.Count -gt 0)
            $r.Events = @(($wer + $appx) | Select-Object -First 20 | ForEach-Object { "$($_.TimeCreated) [$($_.ProviderName)] $($_.Message -replace '\s+',' ')".Substring(0, [Math]::Min(400, "$($_.TimeCreated) [$($_.ProviderName)] $($_.Message -replace '\s+',' ')".Length)) })
        } catch {
            $r.Error = $_.Exception.Message
        }
        [pscustomobject]$r
    }

    $result.Launched          = [bool]$probe.Launched
    $result.ProcessAlive      = [bool]$probe.ProcessAlive
    $result.WindowAppeared    = $probe.WindowAppeared
    $result.CrashDetected     = [bool]$probe.CrashDetected
    $result.EventLogArtifacts = @($probe.Events)

    if (-not $probe.Launched)      { $null = $reasons.Add("Launch failed: $($probe.Error)") }
    if (-not $probe.ProcessAlive)  { $null = $reasons.Add("No packaged process alive after ${SettleSeconds}s.") }
    if ($probe.CrashDetected)      { $null = $reasons.Add('Windows Error Reporting recorded a crash during the run.') }
    if ($RequireWindow -and $probe.WindowAppeared -eq $false) { $null = $reasons.Add('No top-level window appeared (-RequireWindow).') }

    $windowOk = (-not $RequireWindow) -or ($probe.WindowAppeared -eq $true)
    $result.Passed = ($result.Installed -and $probe.Launched -and $probe.ProcessAlive -and -not $probe.CrashDetected -and $windowOk)
    $result.Reasons = @($reasons)

    if (-not $KeepInstalled) {
        _MsixInvokeInVM -VMName $VMName -Credential $Credential -ArgumentList @('cleanup', $identityName) -ScriptBlock {
            param($phase, $idName)
            Get-AppxPackage -Name $idName | Remove-AppxPackage -ErrorAction SilentlyContinue
        } | Out-Null
        if ($Checkpoint) {
            _MsixRestoreVMCheckpoint -VMName $VMName -Checkpoint $Checkpoint
        }
    }

    $verdict = if ($result.Passed) { 'Info' } else { 'Warning' }
    Write-MsixLog -Level $verdict -Message "Test-MsixDeployment: $($result.PackageFullName) Passed=$($result.Passed) ($($reasons -join '; '))"
    [pscustomobject]$result
}