Files
PowerToys/.pipelines/v2/templates/job-test-project.yml
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

331 lines
14 KiB
YAML

parameters:
- name: configuration
type: string
default: "Release"
- name: platform
type: string
default: ""
- name: inputArtifactStem
type: string
default: ""
- name: useLatestWebView2
type: boolean
default: false
- name: buildSource
type: string
default: "latestMainOfficialBuild"
displayName: "Build Source"
- name: specificBuildId
type: string
default: "xxxx"
displayName: "Build ID (for specific builds)"
- name: uiTestModules
type: object
default: []
- name: installMode
type: string
default: 'machine'
values:
- 'machine'
- 'peruser'
- name: jobSuffix
type: string
default: ''
jobs:
- job: Test${{ parameters.platform }}${{ parameters.configuration }}${{ parameters.jobSuffix }}
displayName: Test ${{ parameters.platform }} ${{ parameters.configuration }}${{ parameters.jobSuffix }}
timeoutInMinutes: 300
variables:
${{ if or(eq(parameters.platform, 'x64Win10'), eq(parameters.platform, 'x64Win11')) }}:
BuildPlatform: x64
${{ else }}:
BuildPlatform: ${{ parameters.platform }}
TestPlatform: ${{ parameters.platform }}
BuildConfiguration: ${{ parameters.configuration }}
SrcPath: $(Build.Repository.LocalPath)
TestArtifactsName: build-${{ variables.BuildPlatform }}-${{ parameters.configuration }}${{ parameters.inputArtifactStem }}
pool:
${{ if eq(variables['System.CollectionId'], 'cb55739e-4afe-46a3-970f-1b49d8ee7564') }}:
${{ if ne(parameters.platform, 'ARM64') }}:
name: SHINE-INT-Testing-x64
${{ if eq(parameters.platform, 'x64Win11') }}:
demands: ImageOverride -equals SHINE-W11-Testing
${{ else }}:
name: SHINE-INT-Testing-arm64
${{ else }}:
${{ if ne(parameters.platform, 'ARM64') }}:
name: SHINE-OSS-Testing-x64
${{ if eq(parameters.platform, 'x64Win11') }}:
demands: ImageOverride -equals SHINE-W11-Testing
${{ else }}:
name: SHINE-OSS-Testing-arm64
steps:
- checkout: self
submodules: false
clean: true
fetchDepth: 1
fetchTags: false
- ${{ if eq(parameters.useLatestWebView2, true) }}:
- powershell: |
$edge_url = 'https://go.microsoft.com/fwlink/?linkid=2084649&Channel=Canary&language=en'
$timeout = New-TimeSpan -Minutes 6
$timeoutSeconds = [int]$timeout.TotalSeconds
$command = {
Invoke-WebRequest -Uri $using:edge_url -OutFile $(Pipeline.Workspace)\MicrosoftEdgeSetup.exe
Write-Host "##[command]Installing Canary channel of Microsoft Edge"
Start-Process $(Pipeline.Workspace)\MicrosoftEdgeSetup.exe -ArgumentList '/silent /install' -Wait
}
$job = Start-Job -ScriptBlock $command
Wait-Job $job -Timeout $timeoutSeconds
if ($job.State -eq "Running") {
Stop-Job $job
Write-Host "##[warning]The job was stopped because it exceeded the time limit."
}
displayName: "Install the latest MSEdge Canary"
- script:
reg add "HKLM\Software\Policies\Microsoft\Edge\WebView2\ReleaseChannels" /v PowerToys.exe /t REG_SZ /d "3"
displayName: "Enable WebView2 Canary Channel"
- ${{ if eq(parameters.buildSource, 'buildNowSlim') }}:
# buildNowSlim: the full build publishes the entire ~14 GB tree, but slim runs against the
# installed product, so fetch only the installer + the staged test binaries from this run's
# full-build artifact.
# IMPORTANT: DownloadPipelineArtifact's itemPattern downloads a file that matches ANY pattern,
# and '!' lines do NOT exclude. Per the task docs they "include files that don't match any
# include pattern", so a '!**/*.pdb' line pulls in every non-pdb file — i.e. the whole product
# tree (~14 GB). Use INCLUDE-ONLY patterns so only the installer + tests folder transfer.
- task: DownloadPipelineArtifact@2
displayName: Download artifacts (slim)
inputs:
buildType: 'current'
artifactName: $(TestArtifactsName)
targetPath: '$(Pipeline.Workspace)/$(TestArtifactsName)'
patterns: |
**/PowerToysSetup*.exe
**/tests/**
- ${{ else }}:
# buildNow (whole tree, run in place) and the official path (small tests-only artifact) both
# download the full named artifact via the Azure CLI ArtifactTool (bulk dedup, parallel). The
# x64 CLI zip runs natively on x64 and under emulation on arm64, so one path serves every arch
# and avoids the arm64 OOM the pipeline task hits on the large full-build artifact.
- template: steps-download-artifacts-with-azure-cli.yml
parameters:
artifactName: $(TestArtifactsName)
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '10.0'
- pwsh: |-
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppDriver.ps1'
displayName: Download and install WinAppDriver
# winappcli (winapp.exe) powers the Microsoft.PowerToys.UITest.Next harness and isn't baked
# into the agent image yet. winget / App Installer isn't available on these agents, so download
# the pinned standalone CLI from its GitHub release. Drop this step once the CLI is pre-staged.
- pwsh: |-
& '$(build.sourcesdirectory)\.pipelines\InstallWinAppCli.ps1' -Platform '$(BuildPlatform)'
displayName: Download and install winappcli (winapp.exe)
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
- task: DownloadPipelineArtifact@2
inputs:
buildType: 'specific'
project: 'Dart'
definition: '76541'
${{ if eq(parameters.buildSource, 'specificBuildId') }}:
buildVersionToDownload: 'specific'
buildId: '${{ parameters.specificBuildId }}'
${{ else }}:
buildVersionToDownload: 'latestFromBranch'
branchName: 'refs/heads/main'
artifactName: 'build-$(BuildPlatform)-Release'
targetPath: '$(Build.ArtifactStagingDirectory)'
${{ if eq(parameters.installMode, 'peruser') }}:
patterns: |
**/PowerToysUserSetup*.exe
${{ else }}:
patterns: |
**/PowerToysSetup*.exe
- ${{ if and(ne(parameters.buildSource, 'buildNow'), ne(parameters.buildSource, 'buildNowSlim')) }}:
- ${{ if eq(parameters.installMode, 'peruser') }}:
- pwsh: |-
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "PerUser"
displayName: Install PowerToys (Per-User)
- ${{ if eq(parameters.installMode, 'machine') }}:
- pwsh: |-
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine"
displayName: Install PowerToys (Machine-Level)
# buildNowSlim: the full build's installer was pulled into the test-artifact folder above (instead
# of the whole ~14 GB tree), so install it and run the tests against the installed product — the
# same model as the official path. Available on every arch.
- ${{ if eq(parameters.buildSource, 'buildNowSlim') }}:
- pwsh: |-
& "$(build.sourcesdirectory)\.pipelines\installPowerToys.ps1" -InstallMode "Machine" -ArtifactPath "$(Pipeline.Workspace)\$(TestArtifactsName)"
displayName: Install PowerToys (Machine-Level)
- ${{ if ne(parameters.platform, 'arm64') }}:
- task: ScreenResolutionUtility@1
inputs:
displaySettings: 'optimal'
# Sign the sparse shell-extension MSIX packages with a machine-trusted TEST certificate so they
# register on unsigned PR builds and the UI tests can drive the real modern (Win11 tier-1) context
# menu instead of the signing-free fallback. Test-only trust anchor; asserts no security. All roots
# are searched recursively (buildNow run-in-place tree + complete machine/per-user installs).
# Image Resizer has no Windows 11 classic-menu fallback, so its focused and all-module jobs require
# the signed/trusted package; unrelated jobs keep this setup best-effort.
- pwsh: |
$packageRoots = @(
"$(Pipeline.Workspace)\$(TestArtifactsName)",
"$env:ProgramFiles\PowerToys",
"$env:LOCALAPPDATA\PowerToys")
$modulesRaw = '${{ join(';', parameters.uiTestModules) }}'
$requiresImageResizer = '$(TestPlatform)' -ne 'x64Win10' -and (
[string]::IsNullOrWhiteSpace($modulesRaw) -or
@($modulesRaw -split ';' | Where-Object { $_ -match 'ImageResizer' }).Count -gt 0)
if ($requiresImageResizer) {
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" `
-PackageRoot $packageRoots `
-RequiredPackage 'ImageResizerContextMenuPackage.msix'
} else {
try {
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" -PackageRoot $packageRoots
} catch {
Write-Host "##vso[task.logissue type=warning]Sparse MSIX signing skipped: $($_.Exception.Message)"
}
}
displayName: "Sign sparse MSIX packages (test trust)"
# Start WinAppDriver once for the whole job — WinAppDriver's documented CI pattern
# (https://github.com/microsoft/WinAppDriver/blob/master/Docs/CI_AzureDevOps.md). Launching it
# detached gives it its own hidden console whose stdin blocks, so it stays alive for the run without
# stealing foreground from non-elevated apps under test. This also avoids reading EOF and exiting the
# moment it starts listening (the failure mode when a test host launches it as a child). The legacy
# UITest harness reuses an already-listening instance rather than
# relaunching it per test, so this removes the per-assembly launch cost. The winappcli-based .Next
# tests don't use WinAppDriver. Best-effort: if the pre-start fails, each assembly still launches its own.
- pwsh: |
$winapp = "C:\Program Files (x86)\Windows Application Driver\WinAppDriver.exe"
if (Test-Path $winapp) {
Start-Process -FilePath $winapp -WindowStyle Hidden
$deadline = (Get-Date).AddSeconds(30)
$ready = $false
while (-not $ready -and (Get-Date) -lt $deadline) {
try {
$client = [System.Net.Sockets.TcpClient]::new()
$client.Connect('127.0.0.1', 4723)
$ready = $client.Connected
$client.Close()
} catch {
Start-Sleep -Milliseconds 500
}
}
if ($ready) {
Write-Host 'WinAppDriver is listening on 127.0.0.1:4723.'
} else {
Write-Host "##vso[task.logissue type=warning]WinAppDriver did not start listening on :4723 within 30s; tests will launch it themselves."
}
} else {
Write-Host "##vso[task.logissue type=warning]WinAppDriver not found at $winapp; tests will launch it themselves."
}
displayName: Start WinAppDriver (shared, persistent)
- pwsh: |
$ErrorActionPreference = 'Stop'
$artifactRoot = "$(Pipeline.Workspace)\$(TestArtifactsName)"
if (-not (Test-Path $artifactRoot)) {
Write-Host "##vso[task.logissue type=error]UI test artifact not found: $artifactRoot"
exit 1
}
# uiTestModules is a template parameter; flatten it to a delimited string for the script.
$modulesRaw = '${{ join(';', parameters.uiTestModules) }}'
$modules = @()
if (-not [string]::IsNullOrWhiteSpace($modulesRaw)) {
$modules = $modulesRaw -split ';' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
}
# Each UI test project is a Microsoft.Testing.Platform app; its entry assembly is paired
# with a *.runtimeconfig.json. Recurse under the staged 'tests' folders (tolerates TFM/RID subfolders).
$entries = Get-ChildItem -Path $artifactRoot -Filter '*.runtimeconfig.json' -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like '*UITests*' -and $_.FullName -match '\\tests\\' }
if ($modules.Count -gt 0) {
$entries = $entries | Where-Object { $n = $_.Name; ($modules | Where-Object { $n -like "*$_*" }).Count -gt 0 }
}
# Run each test assembly once (a project reference can copy a runner into a sibling's output).
$entries = $entries | Sort-Object FullName | Group-Object Name | ForEach-Object { $_.Group[0] }
if (-not $entries) {
Write-Host "##vso[task.logissue type=error]No UI test runners matched (modules: '$modulesRaw') under $artifactRoot"
exit 1
}
$resultsDir = "$(Common.TestResultsDirectory)"
New-Item -ItemType Directory -Path $resultsDir -Force | Out-Null
$failed = 0
foreach ($rc in ($entries | Sort-Object FullName -Unique)) {
$base = $rc.Name -replace '\.runtimeconfig\.json$', ''
$dir = $rc.DirectoryName
$exe = Join-Path $dir "$base.exe"
$dll = Join-Path $dir "$base.dll"
Write-Host "##[group]Run UI tests: $base"
Push-Location $dir
try {
if (Test-Path $exe) {
& $exe --report-trx --results-directory $resultsDir
} elseif (Test-Path $dll) {
& dotnet $dll --report-trx --results-directory $resultsDir
} else {
Write-Warning "No runner (exe/dll) found for $base in $dir"
}
if ($LASTEXITCODE -ne 0) {
Write-Warning "UI tests reported failures for $base (exit $LASTEXITCODE)"
$failed++
}
} finally {
Pop-Location
Write-Host "##[endgroup]"
}
}
if ($failed -gt 0) {
Write-Host "##vso[task.logissue type=error]$failed UI test project(s) reported failures."
exit 1
}
displayName: "Run UI Tests"
# Expose 'platform' as an environment variable so the harness's EnvironmentConfig.IsInPipeline
# is true and it captures failure media (screenshots / recording / logs). The legacy VSTest task
# set `env: { platform: $(TestPlatform) }`; the MTP migration to this pwsh step dropped it.
env:
platform: $(TestPlatform)
- task: PublishTestResults@2
displayName: "Publish UI Test Results"
condition: always()
inputs:
testResultsFormat: VSTest
testResultsFiles: '$(Common.TestResultsDirectory)/**/*.trx'
mergeTestResults: true
failTaskOnFailedTests: false
# Stop the shared WinAppDriver (paired with the start step above) so it doesn't linger on the
# self-hosted agent between jobs. Best-effort and always runs.
- pwsh: |
Get-Process -Name 'WinAppDriver' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
displayName: Stop WinAppDriver
condition: always()