Files
PowerToys/.github/skills/ui-tests-local-vm/scripts/Invoke-LocalVmUiTest.ps1
Gleb Khmyznikov e48152c52d [UITests] Add UITest.Next suites (Image Resizer, Peek, File Explorer add‑ons, File Locksmith) + local‑VM tooling and CI test‑signing (#49671)
## Summary

Adds end‑to‑end UI tests on the `Microsoft.PowerToys.UITest.Next`
(winappcli) framework for three
modules, grows the shared `.Next` test framework with the helpers those
suites needed, and adds the
CI plumbing that lets shell‑extension tests exercise the **real**
Windows 11 modern context menu.
Also ships two agent skills that document how to write and run these
tests.

Product runtime behavior is **unchanged** — the only product edits are
test‑observability hooks in Peek
and a unit‑test project exclude.

Closes: https://github.com/microsoft/PowerToys/issues/40660
https://github.com/microsoft/PowerToys/issues/49424
https://github.com/microsoft/PowerToys/issues/40661

## What's added

### New UI test suites
- **Image Resizer** —
`src/modules/imageresizer/tests/ImageResizer.UITests`: context‑menu
enable/disable
tracking, the resize dialog, custom presets, every fit mode, every unit,
filename format, keep‑date,
  shrink‑only, replace‑in‑place, and orientation.
- **Peek** — `src/modules/peek/Peek.UITests.Next`: file‑preview coverage
across image/text/archive/
  markdown types with per‑arch visual baselines.
- **File Explorer add‑ons** —
`src/modules/previewpane/PreviewPane.UITests`: Preview Pane handlers and
  thumbnail providers.

### `UITestAutomation.Next` framework
- New helpers: `ExplorerShell` (Shell selection/view‑mode interop),
`WaitHelper` (structured stable
waits), `WindowControl` (foreground/context‑menu/process control),
`VisualAssert` (image compare),
  `WindowHelper`.
- Updates to `Session`, `UITestBase`, `SettingsConfigHelper`,
`WinappCli`.
- New `UITestAutomation.Next.UnitTests` project covering the new
wait/settings/CLI helpers.

### CI — sign sparse MSIX so the modern menu registers
- **`.pipelines/signSparsePackages.ps1`** — self‑signs each sparse
context‑menu MSIX with a
publisher‑matching test certificate and force‑trusts it (machine
stores), so
`AddPackageByUriAsync` succeeds on otherwise‑unsigned PR builds. Robust
`signtool` discovery with a
  NuGet fallback; test‑only trust that asserts no security.
- Wired into **`.pipelines/v2/templates/job-test-project.yml`** as a
best‑effort step covering the
run‑in‑place, machine‑install, and per‑user‑install locations. Signs
nothing it can't (skips
  already‑signed packages) and never fails the job.

### Product changes (test observability only)
- **Peek `FilePreview.xaml` / `.xaml.cs`** — a named `LoadingIndicator`
and a hidden automation peer
that exposes the current preview state as text, so tests can read load
state deterministically. No
  runtime behavior change.
- **`ImageResizer.UnitTests.csproj`** — exclude the sibling
`ImageResizer.UITests\**` folder from the
  unit‑test compilation.

### Agent skills & docs
- **New `ui-tests-local-vm` skill** — run `.Next` suites in persistent
dockur/windows VMs: setup,
agentic loop, image customization, troubleshooting, the shell‑extension
**signing** reference, plus
  controller/guest scripts and VM templates.
- **Updated `ui-tests-migration` skill** — WinAppDriver/Selenium →
`.Next` porting guidance
(CI stability, Explorer/shell‑extension test design, patterns &
pitfalls).
- **`doc/devdocs/development/ui-tests.md`** — updated for the `.Next`
workflow.

## Testing
- All three suites pass locally and in CI across **x64 Win10**, **x64
Win11**, and **arm64** (machine
  and per‑user install legs).

## Reviewer notes
- No product runtime behavior changes; product edits are limited to the
Peek test hooks above.
- The CI signing step is a **test‑only** trust anchor (self‑signed,
scoped to the agent) and is
best‑effort, so it can only add modern‑menu coverage and never regress
the job.
2026-08-10 19:55:03 +02:00

696 lines
31 KiB
PowerShell

# Copyright (c) Microsoft Corporation
# The Microsoft Corporation licenses this file to you under the MIT license.
# See the LICENSE file in the project root for more information.
<#
.SYNOPSIS
Runs PowerToys UITest.Next executables in a persistent local Hyper-V VM.
.DESCRIPTION
Creates a hash-addressed request, starts or reuses the guest, verifies a non-admin interactive
desktop, dispatches the shared guest runner through Task Scheduler, and returns durable status/TRX
evidence.
The control channel is PowerShell Direct over VMBus and payloads move with Copy-VMFile, so the guest
needs no listener, no published port, and no network. Hyper-V access is required: either an elevated
shell or membership in the local Hyper-V Administrators group.
.EXAMPLE
pwsh ./Invoke-LocalVmUiTest.ps1 `
-VmName PowerToysUiTest-Win10 `
-VmRoot X:\PowerToysUiTestVm `
-ExchangeRoot X:\PowerToysUiTestVm\shared\PowerToysUiTests\Peek `
-TestExecutable Peek.UITests.Next.exe `
-Filter 'Name=Peek.Preview.PDF' `
-Platform x64Win10 `
-BuildLabel (git rev-parse HEAD) `
-ReuseStagedPayload
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Container })]
[string]$VmRoot,
[Parameter(Mandatory)]
[ValidateScript({ Test-Path $_ -PathType Container })]
[string]$ExchangeRoot,
[Parameter(Mandatory)]
[string[]]$TestExecutable,
[Parameter(Mandatory)]
[string]$VmName,
[string]$ConfigurationPath,
[ValidateSet('Default', 'Constrained')]
[string]$ResourceProfile = 'Default',
[string]$Filter,
# Flows to the guest as the 'platform' environment variable. The framework uses it for visual
# baseline filenames (VisualAssert) and treats any non-empty value as "running in a pipeline",
# so it must match the names CI uses or baselines silently fail to resolve.
[ValidateSet('x64Win10', 'x64Win11', 'ARM64')]
[string]$Platform = 'x64Win10',
[string]$BuildLabel = 'local',
[string]$TestsArchive = 'ui-tests.zip',
[string]$ProductArchive = 'powertoys-runtime.zip',
[string]$WinAppCliArchive = 'winappcli.zip',
[string]$DotNetArchive = 'dotnet-runtime.zip',
[string]$ProductOverlayArchive,
[string]$WebView2Installer = 'MicrosoftEdgeWebView2RuntimeInstallerX64.exe',
[string]$SuiteTimeout = '45m',
[string[]]$CleanupProcess = @(),
[ValidateRange(0, 300)]
[int]$OutputHeartbeatSeconds = 15,
[ValidateRange(0, 7680)]
[int]$DesktopWidth = 1920,
[ValidateRange(0, 4320)]
[int]$DesktopHeight = 1080,
[ValidateRange(1, 1440)]
[int]$TimeoutMinutes = 60,
[ValidateRange(1, 120)]
[int]$StartupTimeoutMinutes = 45,
[string]$StandardUser = 'PTUser',
[string]$GuestExchangeRoot = 'C:\PowerToysUiTestExchange',
[string]$CredentialPath,
[string]$GuestRunnerSource = (Join-Path $PSScriptRoot '..\templates\run-ui-tests.ps1'),
[switch]$InstallWebView2,
[switch]$ReuseStagedPayload,
[switch]$SkipStart,
[switch]$StopVmAfterRun,
[switch]$PlanOnly
)
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw 'Run this controller with PowerShell 7 (pwsh).'
}
if (($DesktopWidth -eq 0) -ne ($DesktopHeight -eq 0)) {
throw 'Set both DesktopWidth and DesktopHeight to 0 to disable display validation.'
}
$CleanupProcess = @($CleanupProcess | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })
Import-Module (Join-Path $PSScriptRoot 'LocalVmGuest.psm1') -Force
$controllerName = 'Hyper-V local VM'
$vmRootPath = [IO.Path]::GetFullPath($VmRoot)
if ([string]::IsNullOrWhiteSpace($ConfigurationPath)) {
$ConfigurationPath = Join-Path $vmRootPath 'vm.config.psd1'
}
$configurationPathResolved = [IO.Path]::GetFullPath($ConfigurationPath)
if (-not (Test-Path $configurationPathResolved -PathType Leaf)) {
throw "VM configuration was not found: $configurationPathResolved"
}
$vmConfiguration = Import-PowerShellDataFile $configurationPathResolved
if ([string]$vmConfiguration.VmName -ne $VmName) {
throw "VM configuration '$configurationPathResolved' names '$($vmConfiguration.VmName)', but -VmName is '$VmName'."
}
if ([string]::IsNullOrWhiteSpace($CredentialPath)) {
$CredentialPath = Join-Path $env:LOCALAPPDATA 'PowerToysUiTestVm\admin.credential.xml'
}
# Hyper-V access, the guest credential, and the guest itself all need a human. Fail on the whole set
# at once so the user gets one actionable instruction instead of three sequential surprises.
if (-not $PlanOnly) {
$guestAdminUser = [string]$vmConfiguration.AdminUserName
$hostSetup = Test-LocalVmHostSetup -VmName $VmName -CredentialPath $CredentialPath -AdminUserName $guestAdminUser
if (-not $hostSetup.IsReady) {
throw (Get-LocalVmSetupMessage `
-Status $hostSetup `
-VmRoot $VmRoot `
-ConfigPath $configurationPathResolved `
-CredentialPath $CredentialPath)
}
}
$exchangePath = [IO.Path]::GetFullPath($ExchangeRoot)
$guestRunnerSourcePath = [IO.Path]::GetFullPath($GuestRunnerSource)
if (-not (Test-Path $guestRunnerSourcePath -PathType Leaf)) {
throw "Guest runner was not found: $guestRunnerSourcePath"
}
$guestContext = New-LocalVmContext `
-VmName $VmName -HostExchangeRoot $exchangePath -GuestExchangeRoot $GuestExchangeRoot
$guestExchangeRoot = $guestContext.GuestExchangeRoot
function Get-ExchangeFileHash {
param([string]$File)
if ([string]::IsNullOrWhiteSpace($File)) {
return $null
}
return (Get-FileHash (Join-Path $exchangePath $File) -Algorithm SHA256).Hash
}
function Get-TrxSummary {
param([Parameter(Mandatory)][string]$ResultRoot)
$suites = @()
$totals = [ordered]@{ Total = 0; Executed = 0; Passed = 0; Failed = 0; Error = 0; NotExecuted = 0 }
foreach ($trx in Get-ChildItem $ResultRoot -Filter '*.trx' -File -Recurse -ErrorAction SilentlyContinue) {
[xml]$document = Get-Content $trx.FullName -Raw
$counters = $document.TestRun.ResultSummary.Counters
$tests = @($document.TestRun.Results.UnitTestResult | ForEach-Object {
[pscustomobject]@{
Name = [string]$_.testName
Outcome = [string]$_.outcome
Duration = [string]$_.duration
ErrorMessage = [string]$_.Output.ErrorInfo.Message
}
})
$suite = [ordered]@{
File = $trx.FullName
Total = [int]$counters.total
Executed = [int]$counters.executed
Passed = [int]$counters.passed
Failed = [int]$counters.failed
Error = [int]$counters.error
NotExecuted = [int]$counters.notExecuted
Tests = $tests
}
$suites += [pscustomobject]$suite
foreach ($name in @('Total', 'Executed', 'Passed', 'Failed', 'Error', 'NotExecuted')) {
$totals[$name] += $suite[$name]
}
}
return [pscustomobject]@{ Totals = [pscustomobject]$totals; Suites = $suites }
}
function Start-InteractiveTask {
param(
[Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session,
[Parameter(Mandatory)][string]$TaskName,
[Parameter(Mandatory)][string]$Executable,
[Parameter(Mandatory)][string]$Arguments,
[Parameter(Mandatory)][string]$UserName,
[Parameter(Mandatory)][int]$ExecutionTimeLimitMinutes
)
return Invoke-Command -Session $Session -ScriptBlock {
param($Name, $FilePath, $ArgumentList, $InteractiveUser, $LimitMinutes)
$ErrorActionPreference = 'Stop'
Unregister-ScheduledTask -TaskName $Name -Confirm:$false -ErrorAction SilentlyContinue
$action = New-ScheduledTaskAction -Execute $FilePath -Argument $ArgumentList
$principal = New-ScheduledTaskPrincipal `
-UserId "$env:COMPUTERNAME\$InteractiveUser" `
-LogonType Interactive -RunLevel Limited
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Minutes $LimitMinutes) `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
$task = New-ScheduledTask -Action $action -Principal $principal -Settings $settings
Register-ScheduledTask -TaskName $Name -InputObject $task -Force | Out-Null
Start-ScheduledTask -TaskName $Name
$registered = Get-ScheduledTask -TaskName $Name
[pscustomobject]@{
TaskName = $Name
State = [string]$registered.State
UserId = $registered.Principal.UserId
RunLevel = [string]$registered.Principal.RunLevel
}
} -ArgumentList $TaskName, $Executable, $Arguments, $UserName, $ExecutionTimeLimitMinutes
}
$requiredFiles = @($TestsArchive, $ProductArchive, $WinAppCliArchive, $DotNetArchive)
if (-not [string]::IsNullOrWhiteSpace($ProductOverlayArchive)) {
$requiredFiles += $ProductOverlayArchive
}
if ($InstallWebView2) {
$requiredFiles += $WebView2Installer
}
foreach ($file in $requiredFiles) {
if (-not (Test-Path (Join-Path $exchangePath $file) -PathType Leaf)) {
throw "Required exchange file is missing: $file"
}
}
$payloadFiles = @($requiredFiles | Sort-Object -Unique)
$payloadHashes = [ordered]@{
Tests = Get-ExchangeFileHash -File $TestsArchive
Product = Get-ExchangeFileHash -File $ProductArchive
ProductOverlay = Get-ExchangeFileHash -File $ProductOverlayArchive
WinAppCli = Get-ExchangeFileHash -File $WinAppCliArchive
DotNet = Get-ExchangeFileHash -File $DotNetArchive
WebView2Installer = if ($InstallWebView2) { Get-ExchangeFileHash -File $WebView2Installer } else { $null }
}
$fingerprintLines = foreach ($file in $payloadFiles) {
'{0}={1}' -f $file, (Get-ExchangeFileHash -File $file)
}
$sha256 = [Security.Cryptography.SHA256]::Create()
try {
$payloadFingerprint = [Convert]::ToHexString(
$sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes($fingerprintLines -join "`n")))
}
finally {
$sha256.Dispose()
}
$runId = 'localvm-{0}-{1}' -f [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N').Substring(0, 8)
$resultRelative = "LocalVmResults\$runId"
$hostResultRoot = Join-Path $exchangePath $resultRelative
$guestResultRoot = Join-Path $guestExchangeRoot $resultRelative
$requestRelative = Join-Path $resultRelative 'request.json'
$statusRelative = Join-Path $resultRelative 'status.json'
$progressRelative = Join-Path $resultRelative 'progress.json'
$probeScriptRelative = Join-Path $resultRelative 'desktop-probe.ps1'
$probeRelative = Join-Path $resultRelative 'desktop-probe.json'
$requestPath = Join-Path $hostResultRoot 'request.json'
$guestRequestPath = Join-Path $guestResultRoot 'request.json'
$guestProbeScriptPath = Join-Path $guestResultRoot 'desktop-probe.ps1'
$guestProbePath = Join-Path $guestResultRoot 'desktop-probe.json'
$guestRunnerName = 'run-ui-tests.ps1'
$hostRunnerPath = Join-Path $exchangePath $guestRunnerName
$guestRunnerPath = Join-Path $guestExchangeRoot $guestRunnerName
New-Item $hostResultRoot -ItemType Directory -Force | Out-Null
Copy-Item $guestRunnerSourcePath $hostRunnerPath -Force
$request = [ordered]@{
RunId = $runId
Controller = $controllerName
ExchangeRoot = $guestExchangeRoot
BuildLabel = $BuildLabel
TestExecutables = @($TestExecutable)
Filter = $Filter
Platform = $Platform
ResourceProfile = $ResourceProfile
SuiteTimeout = $SuiteTimeout
OutputHeartbeatSeconds = $OutputHeartbeatSeconds
DesktopWidth = $DesktopWidth
DesktopHeight = $DesktopHeight
ReuseStagedPayload = [bool]$ReuseStagedPayload
PayloadFingerprint = $payloadFingerprint
PayloadFiles = $payloadFiles
PayloadHashes = $payloadHashes
Archives = [ordered]@{
Tests = $TestsArchive
Product = $ProductArchive
WinAppCli = $WinAppCliArchive
DotNet = $DotNetArchive
ProductOverlay = $ProductOverlayArchive
}
WebView2Installer = if ($InstallWebView2) { $WebView2Installer } else { $null }
CleanupProcesses = @($CleanupProcess)
}
$requestJson = $request | ConvertTo-Json -Depth 8
$requestJson | Set-Content $requestPath -Encoding utf8
$plan = [ordered]@{
Controller = $controllerName
RunId = $runId
VmRoot = $vmRootPath
VmName = $guestContext.VmName
ConfigurationPath = $configurationPathResolved
ResourceProfile = $ResourceProfile
ExchangeRoot = $exchangePath
GuestExchangeRoot = $guestExchangeRoot
GuestRunnerSource = $guestRunnerSourcePath
GuestRequestPath = $guestRequestPath
StandardUser = $StandardUser
ControlChannel = $guestContext.ControlChannel
ReuseStagedPayload = [bool]$ReuseStagedPayload
StopVmAfterRun = [bool]$StopVmAfterRun
PayloadFingerprint = $payloadFingerprint
PayloadHashes = $payloadHashes
}
$plan | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $hostResultRoot 'controller-plan.json') -Encoding utf8
if ($PlanOnly) {
[pscustomobject]@{
Status = 'PLAN'
RunId = $runId
ResultsPath = $hostResultRoot
RequestPath = $requestPath
PayloadFingerprint = $payloadFingerprint
} | ConvertTo-Json
return
}
if (-not (Test-Path $CredentialPath -PathType Leaf)) {
throw "DPAPI credential file was not found: $CredentialPath"
}
$credential = Import-Clixml $CredentialPath
if ($credential -isnot [System.Management.Automation.PSCredential]) {
throw "Credential file does not contain a PSCredential: $CredentialPath"
}
$session = $null
$evidenceExported = $false
$probeTaskName = "PowerToysUiTest-Probe-$runId"
$testTaskName = "PowerToysUiTest-Run-$runId"
$controllerResult = $null
try {
if (-not $SkipStart) {
$startScript = Join-Path $vmRootPath 'Start-LocalVm.ps1'
if (-not (Test-Path $startScript -PathType Leaf)) {
throw "VM start script was not found: $startScript"
}
$startupOutput = & $startScript `
-ConfigPath $configurationPathResolved `
-CredentialPath $CredentialPath `
-ResourceProfile $ResourceProfile `
-Wait `
-TimeoutMinutes $StartupTimeoutMinutes | Out-String
Write-Verbose $startupOutput
}
$session = New-LocalVmSession `
-Context $guestContext -Credential $credential -TimeoutMinutes $StartupTimeoutMinutes
$guestWindowsVersion = Invoke-Command -Session $session -ScriptBlock {
$currentVersion = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
[pscustomobject]@{
Build = [int]$currentVersion.CurrentBuild
Ubr = [int]$currentVersion.UBR
Display = "$($currentVersion.CurrentBuild).$($currentVersion.UBR)"
}
}
if ($guestWindowsVersion.Build -lt 22000 -and
($guestWindowsVersion.Build -lt 19041 -or $guestWindowsVersion.Build -gt 19045 -or $guestWindowsVersion.Ubr -lt 5007)) {
throw "BLOCKED: '$VmName' is Windows $($guestWindowsVersion.Display). .NET 10 and PowerShell 7.6 require Windows 10 1904x.5007 or newer for CET. Recreate through Initialize-LocalVmHost.ps1 (Setup Dynamic Update) or run Update-LocalVmGuest.ps1."
}
$vcArchitecture = if ([string]$vmConfiguration.ProcessorArchitecture -eq 'arm64') { 'arm64' } else { 'x64' }
$vcRuntimePresent = Invoke-Command -Session $session -ScriptBlock {
Test-Path "$env:WINDIR\System32\VCRUNTIME140.dll" -PathType Leaf
}
if (-not $vcRuntimePresent) {
$vcRedistPath = Join-Path $vmRootPath "oem\vc_redist.$vcArchitecture.exe"
if (-not (Test-Path $vcRedistPath -PathType Leaf)) {
throw "BLOCKED: '$VmName' cannot record failure video because VCRUNTIME140.dll is missing, and no repair payload exists at '$vcRedistPath'. Run Initialize-LocalVmHost.ps1 for this VM config."
}
$vcSignature = Get-AuthenticodeSignature $vcRedistPath
if ($vcSignature.Status -ne 'Valid' -or $vcSignature.SignerCertificate.Subject -notlike 'CN=Microsoft Corporation*') {
throw "BLOCKED: refusing untrusted VC++ redistributable '$vcRedistPath' (status=$($vcSignature.Status))."
}
Invoke-Command -Session $session -ScriptBlock {
New-Item C:\PowerToysUiTestTools -ItemType Directory -Force | Out-Null
}
$guestVcRedist = 'C:\PowerToysUiTestTools\vc_redist.exe'
Copy-Item $vcRedistPath -Destination $guestVcRedist -ToSession $session -Force
$vcExitCode = Invoke-Command -Session $session -ScriptBlock {
param($Installer)
(Start-Process $Installer -ArgumentList '/install', '/quiet', '/norestart' -Wait -PassThru).ExitCode
} -ArgumentList $guestVcRedist
if ($vcExitCode -notin 0, 1638, 3010 -or -not (Invoke-Command -Session $session -ScriptBlock { Test-Path "$env:WINDIR\System32\VCRUNTIME140.dll" })) {
throw "BLOCKED: Visual C++ redistributable installation failed in '$VmName' (exit=$vcExitCode)."
}
Write-Host "Installed the Visual C++ runtime in '$VmName' for failure-video capture."
}
$guestPowerShell = 'C:\Program Files\PowerShell\7\pwsh.exe'
$powerShellPresent = Invoke-Command -Session $session -ScriptBlock {
param($Path)
Test-Path $Path -PathType Leaf
} -ArgumentList $guestPowerShell
if (-not $powerShellPresent) {
$powerShellMsi = Get-ChildItem (Join-Path $vmRootPath 'oem') `
-Filter "PowerShell-*-win-$vcArchitecture.msi" -File |
Where-Object { Test-Path "$($_.FullName).sha256" -PathType Leaf } |
Sort-Object Name -Descending | Select-Object -First 1
if ($null -eq $powerShellMsi) {
throw "BLOCKED: PowerShell 7 is missing in '$VmName', and no verified repair MSI exists under '$(Join-Path $vmRootPath 'oem')'. Run Initialize-LocalVmHost.ps1 for this VM config."
}
$expectedPowerShellHash = (Get-Content "$($powerShellMsi.FullName).sha256" -Raw).Trim()
$actualPowerShellHash = (Get-FileHash $powerShellMsi.FullName -Algorithm SHA256).Hash
$powerShellSignature = Get-AuthenticodeSignature $powerShellMsi.FullName
if ($actualPowerShellHash -ne $expectedPowerShellHash -or
$powerShellSignature.Status -ne 'Valid' -or
$powerShellSignature.SignerCertificate.Subject -notlike 'CN=Microsoft Corporation*') {
throw "BLOCKED: refusing unverified PowerShell MSI '$($powerShellMsi.FullName)' (sha256=$actualPowerShellHash, expected=$expectedPowerShellHash, status=$($powerShellSignature.Status))."
}
Invoke-Command -Session $session -ScriptBlock {
New-Item C:\PowerToysUiTestTools -ItemType Directory -Force | Out-Null
}
$guestPowerShellMsi = 'C:\PowerToysUiTestTools\PowerShell.msi'
Copy-Item $powerShellMsi.FullName -Destination $guestPowerShellMsi -ToSession $session -Force
$powerShellExitCode = Invoke-Command -Session $session -ScriptBlock {
param($Installer)
(Start-Process msiexec.exe -ArgumentList @(
'/i', $Installer, '/qn', '/norestart',
'ADD_PATH=1', 'REGISTER_MANIFEST=1',
'ENABLE_PSREMOTING=0', 'USE_MU=0', 'ENABLE_MU=0') -Wait -PassThru).ExitCode
} -ArgumentList $guestPowerShellMsi
if ($powerShellExitCode -notin 0, 1638, 3010 -or -not (Invoke-Command -Session $session -ScriptBlock {
param($Path)
Test-Path $Path -PathType Leaf
} -ArgumentList $guestPowerShell)) {
throw "BLOCKED: PowerShell 7 installation failed in '$VmName' (exit=$powerShellExitCode)."
}
Write-Host "Installed PowerShell 7 in '$VmName' for guest-side test orchestration."
}
Initialize-GuestExchange -Context $guestContext -Session $session -StandardUser $StandardUser
$stagedFiles = @(Copy-ToGuest `
-Context $guestContext -Session $session -FileName (@($guestRunnerName) + $payloadFiles))
if ($stagedFiles.Count -gt 0) {
Write-Host "Staged into the guest: $($stagedFiles -join ', ')"
}
Write-GuestText `
-Context $guestContext -Session $session -RelativePath $requestRelative -Value $requestJson
$controlIdentity = Invoke-Command -Session $session -ScriptBlock {
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$principal = [Security.Principal.WindowsPrincipal]$identity
$windowsApplicationId = '55c92734-d682-4d71-983e-d6ec3f16059f'
$windowsLicense = Get-CimInstance SoftwareLicensingProduct -ErrorAction SilentlyContinue |
Where-Object {
$_.ApplicationID -eq $windowsApplicationId -and
-not [string]::IsNullOrWhiteSpace($_.PartialProductKey) -and
$_.Name -like 'Windows*'
} |
Select-Object -First 1
[pscustomobject]@{
User = $identity.Name
IsAdministrator = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
WindowsLicenseDescription = [string]$windowsLicense.Description
WindowsLicenseStatus = [int]$windowsLicense.LicenseStatus
WindowsGracePeriodMinutes = [int]$windowsLicense.GracePeriodRemaining
}
}
if (-not $controlIdentity.IsAdministrator) {
throw "The control identity '$($controlIdentity.User)' is not an administrator."
}
if ($controlIdentity.WindowsLicenseDescription.Contains('TIMEBASED_EVAL', [StringComparison]::OrdinalIgnoreCase) -and
([int]$controlIdentity.WindowsLicenseStatus -eq 5 -or [int]$controlIdentity.WindowsGracePeriodMinutes -le 0)) {
throw 'The Windows evaluation period has expired. The guest will shut down hourly; replace it with current evaluation media or a properly licensed baseline before running UI tests.'
}
$escapedProbePath = $guestProbePath.Replace("'", "''")
$escapedExchangeRoot = $guestExchangeRoot.Replace("'", "''")
$probeScript = @"
`$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
`$principal = [Security.Principal.WindowsPrincipal]`$identity
`$sessionId = (Get-Process -Id `$PID).SessionId
Add-Type -AssemblyName System.Windows.Forms
[ordered]@{
User = `$identity.Name
SessionId = `$sessionId
IsAdministrator = `$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
ExplorerCount = @(Get-Process explorer -ErrorAction SilentlyContinue | Where-Object SessionId -eq `$sessionId).Count
DesktopWidth = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width
DesktopHeight = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Height
ExchangeAccessible = Test-Path '$escapedExchangeRoot'
} | ConvertTo-Json | Set-Content '$escapedProbePath' -Encoding utf8
"@
Write-GuestText `
-Context $guestContext -Session $session -RelativePath $probeScriptRelative -Value $probeScript
$probeArguments = '-NoLogo -NoProfile -ExecutionPolicy Bypass -File "{0}"' -f $guestProbeScriptPath
$probeTask = Start-InteractiveTask `
-Session $session -TaskName $probeTaskName `
-Executable $guestPowerShell -Arguments $probeArguments `
-UserName $StandardUser -ExecutionTimeLimitMinutes 2
Write-Host "Desktop probe task: $($probeTask.TaskName), user $($probeTask.UserId)"
$probeDeadline = [DateTime]::UtcNow.AddMinutes(2)
do {
$desktopProbe = Read-GuestJson `
-Context $guestContext -Session $session -RelativePath $probeRelative -Attempts 3
if ($null -ne $desktopProbe) {
break
}
if ([DateTime]::UtcNow -ge $probeDeadline) {
$probeTaskInfo = Invoke-Command -Session $session -ScriptBlock {
param($Name)
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
$info = Get-ScheduledTaskInfo -TaskName $Name -ErrorAction SilentlyContinue
[pscustomobject]@{ State = [string]$task.State; LastTaskResult = $info.LastTaskResult }
} -ArgumentList $probeTaskName
throw "Interactive desktop probe did not complete. Task state: $($probeTaskInfo.State); result: $($probeTaskInfo.LastTaskResult)."
}
Start-Sleep -Seconds 1
} while ($true)
if ($desktopProbe.IsAdministrator) {
throw "Interactive test user '$($desktopProbe.User)' is an administrator."
}
if (-not $desktopProbe.User.EndsWith("\$StandardUser", [StringComparison]::OrdinalIgnoreCase)) {
throw "Interactive task ran as '$($desktopProbe.User)', expected '$StandardUser'."
}
if ([int]$desktopProbe.SessionId -le 0 -or [int]$desktopProbe.ExplorerCount -le 0) {
throw "No interactive Explorer desktop is available for '$StandardUser'."
}
if (-not $desktopProbe.ExchangeAccessible) {
throw "The interactive user cannot access '$guestExchangeRoot'."
}
if ($DesktopWidth -ne 0 -and
([int]$desktopProbe.DesktopWidth -ne $DesktopWidth -or [int]$desktopProbe.DesktopHeight -ne $DesktopHeight)) {
throw "Guest desktop is $($desktopProbe.DesktopWidth)x$($desktopProbe.DesktopHeight); expected ${DesktopWidth}x${DesktopHeight}."
}
$runnerArguments = '-NoLogo -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -RequestPath "{1}"' -f $guestRunnerPath, $guestRequestPath
$taskLimitMinutes = [Math]::Min(1440, $TimeoutMinutes + 5)
$testTask = Start-InteractiveTask `
-Session $session -TaskName $testTaskName `
-Executable $guestPowerShell -Arguments $runnerArguments `
-UserName $StandardUser -ExecutionTimeLimitMinutes $taskLimitMinutes
Write-Host "UI-test task: $($testTask.TaskName), user $($testTask.UserId)"
$deadline = [DateTime]::UtcNow.AddMinutes($TimeoutMinutes)
$lastProgress = $null
$status = $null
do {
$progress = Read-GuestJson `
-Context $guestContext -Session $session -RelativePath $progressRelative
if ($null -ne $progress) {
$progressKey = "$($progress.Stage):$($progress.Detail)"
if ($progressKey -ne $lastProgress) {
Write-Host "[$($progress.Stage)] $($progress.Detail)"
$lastProgress = $progressKey
}
}
$candidate = Read-GuestJson `
-Context $guestContext -Session $session -RelativePath $statusRelative
if ($null -ne $candidate -and $candidate.RunId -eq $runId) {
$status = $candidate
break
}
if ([DateTime]::UtcNow -ge $deadline) {
# Absorb a status file that was still being created when the deadline elapsed.
$candidate = Read-GuestJson `
-Context $guestContext -Session $session -RelativePath $statusRelative -Attempts 20
if ($null -ne $candidate -and $candidate.RunId -eq $runId) {
$status = $candidate
break
}
$taskInfo = Invoke-Command -Session $session -ScriptBlock {
param($Name)
$task = Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue
$info = Get-ScheduledTaskInfo -TaskName $Name -ErrorAction SilentlyContinue
[pscustomobject]@{ State = [string]$task.State; LastTaskResult = $info.LastTaskResult }
} -ArgumentList $testTaskName
throw "Local VM UI-test run timed out. Task state: $($taskInfo.State); result: $($taskInfo.LastTaskResult)."
}
Start-Sleep -Seconds 1
} while ($true)
Copy-FromGuest -Context $guestContext -Session $session -RelativePath $resultRelative
$evidenceExported = $true
$trx = Get-TrxSummary -ResultRoot $hostResultRoot
$nonPassingTests = @($trx.Suites | ForEach-Object { $_.Tests } | Where-Object { $_.Outcome -ne 'Passed' })
$effectiveExitCode = [int]$status.ExitCode
$effectiveStatus = [string]$status.Status
if ($trx.Suites.Count -eq 0 -or
$trx.Totals.Total -eq 0 -or
$trx.Totals.Executed -ne $trx.Totals.Total -or
$nonPassingTests.Count -gt 0) {
$effectiveStatus = 'FAIL'
if ($effectiveExitCode -eq 0) {
$effectiveExitCode = 1
}
}
$controllerResult = [pscustomobject]@{
Controller = $controllerName
RunId = $runId
BuildLabel = $status.BuildLabel
Status = $effectiveStatus
ExitCode = $effectiveExitCode
ResultsPath = $hostResultRoot
ControlUser = $controlIdentity.User
GuestUser = $status.User
GuestSessionId = $status.SessionId
DesktopWidth = $status.DesktopWidth
DesktopHeight = $status.DesktopHeight
ResourceProfile = $ResourceProfile
ReusedStagedPayload = $status.ReusedStagedPayload
RefreshedComponents = $status.RefreshedComponents
ExportErrors = $status.ExportErrors
Tests = $trx.Totals
Failed = @($nonPassingTests | ForEach-Object {
[pscustomobject]@{
Name = $_.Name
Outcome = $_.Outcome
Duration = $_.Duration
Error = (($_.ErrorMessage -split "`n") | Select-Object -First 1)
}
})
Suites = $trx.Suites
}
}
finally {
if ($null -ne $session) {
if ($session.State -eq 'Opened') {
if (-not $evidenceExported) {
try {
Copy-FromGuest -Context $guestContext -Session $session -RelativePath $resultRelative
$evidenceExported = $true
}
catch {
Write-Warning "Guest evidence could not be exported: $($_.Exception.Message)"
}
}
if ($evidenceExported) {
try {
Remove-GuestItem -Context $guestContext -Session $session -RelativePath $resultRelative
}
catch {
Write-Warning "The guest run folder could not be removed: $($_.Exception.Message)"
}
}
}
try {
if ($session.State -eq 'Opened') {
Invoke-Command -Session $session -ScriptBlock {
param($ProbeTaskName, $TestTaskName)
foreach ($name in @($ProbeTaskName, $TestTaskName)) {
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
}
} -ArgumentList $probeTaskName, $testTaskName -ErrorAction Stop
}
}
catch {
Write-Warning "Scheduled-task cleanup was skipped because the guest session was unavailable: $($_.Exception.Message)"
}
finally {
Remove-PSSession $session -ErrorAction SilentlyContinue
}
}
if ($StopVmAfterRun) {
$stopScript = Join-Path $vmRootPath 'Stop-LocalVm.ps1'
if (Test-Path $stopScript -PathType Leaf) {
try {
& $stopScript `
-ConfigPath $configurationPathResolved `
-CredentialPath $CredentialPath | Out-Host
}
catch {
Write-Warning "The requested post-run VM stop failed: $($_.Exception.Message)"
}
}
}
}
if ($null -ne $controllerResult) {
$controllerResult | ConvertTo-Json -Depth 8
if ($controllerResult.ExitCode -ne 0) {
exit $controllerResult.ExitCode
}
}