Files
PowerToys/.pipelines/v2/templates/job-test-project.yml
Gleb Khmyznikov bea1b8e247 [UITests][PowerRename] Migrate to new .Next and add more UI tests (#50096)
## Summary of the Pull Request

Adds a `PowerRename.UITests.Next` suite powered by winappcli and
automates all 18 scenarios from #40663. The suite covers PowerRename
settings, search and replace behavior, regular expressions, formatting
and filtering options, file-list interactions, and both classic and
Windows 11 context-menu workflows.

The PR also stabilizes shared `UITestAutomation.Next` runner lifetimes
and settings restoration, adds automation IDs for the original and
renamed counters, and prepares unsigned CI builds for PowerRename shell
testing. CI now signs the sparse context-menu MSIX and the
runner/Settings IPC companions with a disposable machine-trusted test
identity.

## PR Checklist

- [x] Closes: #40663
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [x] **Dev docs:** Added/updated
- [x] **New binaries:** Not applicable; no new shipped product binaries
are added
   - [x] JSON for signing: Not applicable
   - [x] WXS for installer: Not applicable
- [x] YML for CI pipeline: The new UI-test project is discovered through
the existing `*UITest*.csproj` pipeline flow and is registered in
`PowerToys.slnx`
   - [x] YML for signed pipeline: Not applicable
- [x] **Documentation updated:** Not applicable; there are no
user-facing behavior or documentation changes

## Detailed Description of the Pull Request / Additional comments

### PowerRename UI tests

- Adds a 25-case `PowerRename.UITests.Next` executable covering all 18
checklist items from #40663.
- Exercises classic context-menu registration on Windows 10 and Windows
11.
- Exercises the signed Windows 11 tier-1 context menu, including icon
visibility and real invocation with an Explorer selection.
- Covers search/replace preview and application, text formatting,
file/folder/subfolder inclusion, filename/extension scope, enumeration,
case sensitivity, match-all behavior, regular expressions, file
timestamps, Boost syntax, MRU autocomplete, persisted values, and
file-list selection/filtering.
- Preserves the existing legacy tests.

### Test reliability and automation hooks

- Reuses one runner/Settings lifetime across the complete PowerRename
suite to avoid repeated cold launches on constrained agents.
- Retains and restores global settings for the full class lifetime and
verifies that both Settings and the runner remain healthy.
- Propagates scope and PowerRename cleanup failures instead of silently
leaking process or profile state.
- Adds `OriginalCount` and `RenamedCount` automation IDs. These are
automation-only metadata and do not change the visible UI.
- Uses stable preview samples, exact count targeting, authoritative
Explorer selection, readable classic-menu inventories, and live UIA
visibility for popup items.

### Unsigned CI build support

- Extends the existing sparse-package test signer with required
Authenticode companion files.
- PowerRename jobs sign `PowerToys.exe` and `PowerToys.Settings.exe`
with the same disposable machine-trusted test identity used for sparse
MSIX packages.
- This preserves Release IPC authentication while allowing Settings
module-toggle commands to work on unsigned PR builds.
- Windows 11 and ARM64 PowerRename jobs require a validly signed
`PowerRenameContextMenuPackage.msix` before tests start.

## Validation Steps Performed

### Builds and discovery

- `PowerRename.UITests.Next` Debug x64 build: passed
- `PowerRename.UITests.Next` Debug ARM64 cross-build: passed
- `PowerRenameUI` Release x64 build: passed
- `UITestAutomation.Next.UnitTests` Debug x64 build: passed
- `UITestAutomation.Next.UnitTests`: 16/16 passed
- Microsoft.Testing.Platform discovery: 25 unique PowerRename test cases

### Local VM matrix

All runs used the complete unfiltered `TestCategory=PowerRename` suite
and restored the standard user's settings file byte-for-byte.

| Guest | Profile | Result |
|---|---|---:|
| Windows 10 x64 | Default, 4 vCPU / 8 GB | 25/25 | 
| Windows 10 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | 
| Windows 11 x64 | Default, 4 vCPU / 8 GB | 25/25 | 
| Windows 11 x64 | Constrained, 1 vCPU / 4 GB | 25/25 | 

### Azure DevOps UI Test Automation

- Final build: [155646235 /
20260824.1](https://dev.azure.com/microsoft/Dart/_build/results?buildId=155646235)
- Source revision: `4496683104aea622b30179909bd6e95a17d5500f`
- ARM64: 25/25 passed
- Windows 10 x64: 25/25 passed
- Windows 11 x64: 25/25 passed
- Total: 75/75 passed, with zero failed, skipped, not-executed, or
unanalyzed results
- ARM64 and x64 Release product builds succeeded and published their
normal artifacts
- Signing steps verified the PowerRename sparse MSIX where applicable
and both IPC companion executables on every PowerRename test job

<img width="372" height="314" alt="image"
src="https://github.com/user-attachments/assets/bc303673-0325-4f85-8d70-ef93110baf5b"
/>
2026-08-26 00:26:10 +02:00

371 lines
16 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
- pwsh: |
& "$(build.sourcesdirectory)\.pipelines\removeTestSigningCertificates.ps1" `
-CertificateMarkerPath "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt"
displayName: Remove stale UI-test signing certificates
- ${{ 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).
# Focused and all-module jobs that exercise a modern context menu require the corresponding
# signed/trusted package; unrelated jobs keep this setup best-effort. The certificate thumbprint
# is recorded under Agent.WorkFolder and removed from every trust/private-key store before signing
# and in the always-running cleanup step below. The durable marker makes interrupted cleanup
# retryable on persistent agents.
- pwsh: |
$packageRoots = @(
"$(Pipeline.Workspace)\$(TestArtifactsName)",
"$env:ProgramFiles\PowerToys",
"$env:LOCALAPPDATA\PowerToys")
$modulesRaw = '${{ join(';', parameters.uiTestModules) }}'
$selectedModules = @($modulesRaw -split ';' | Where-Object { $_ })
$allModules = [string]::IsNullOrWhiteSpace($modulesRaw)
$requiresModernContextMenu = '$(TestPlatform)' -ne 'x64Win10'
$requiresPowerRename = $allModules -or @($selectedModules | Where-Object { $_ -match 'PowerRename' }).Count -gt 0
$requiredPackages = @()
$requiredAuthenticodeFiles = @()
$certificateMarkerPath = "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt"
if ($requiresModernContextMenu -and ($allModules -or @($selectedModules | Where-Object { $_ -match 'ImageResizer' }).Count -gt 0)) {
$requiredPackages += 'ImageResizerContextMenuPackage.msix'
}
if ($requiresModernContextMenu -and $requiresPowerRename) {
$requiredPackages += 'PowerRenameContextMenuPackage.msix'
}
if ($requiresPowerRename) {
$requiredAuthenticodeFiles += 'PowerToys.exe', 'PowerToys.Settings.exe'
}
if ($requiredPackages.Count -gt 0 -or $requiredAuthenticodeFiles.Count -gt 0) {
$signingArguments = @{
PackageRoot = $packageRoots
CertificateMarkerPath = $certificateMarkerPath
}
if ($requiredPackages.Count -gt 0) {
$signingArguments.RequiredPackage = $requiredPackages
}
if ($requiredAuthenticodeFiles.Count -gt 0) {
$signingArguments.RequiredAuthenticodeFile = $requiredAuthenticodeFiles
}
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" @signingArguments
} else {
try {
& "$(build.sourcesdirectory)\.pipelines\signSparsePackages.ps1" `
-PackageRoot $packageRoots `
-CertificateMarkerPath $certificateMarkerPath
} 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()
- pwsh: |
& "$(build.sourcesdirectory)\.pipelines\removeTestSigningCertificates.ps1" `
-CertificateMarkerPath "$(Agent.WorkFolder)\PowerToysUiTestState\SigningCertificates.txt"
displayName: Remove UI-test signing certificates
condition: always()