Compare commits

...

32 Commits

Author SHA1 Message Date
gkhmyznikov
5e5be0e75a address the comments 2026-06-25 11:50:27 -07:00
Gleb Khmyznikov
c0ff2214d5 Update .pipelines/InstallWinAppCli.ps1
Co-authored-by: Boliang Zhang <122517415+LegendaryBlair@users.noreply.github.com>
2026-06-25 10:05:56 -07:00
gkhmyznikov
8900ae0835 fix the settings window sizing 2026-06-23 13:45:50 -07:00
gkhmyznikov
8dcd2d48cd try to fix old test workflow 2026-06-23 10:32:35 -07:00
gkhmyznikov
9c9566d1fd fix the nudge 2026-06-22 19:48:58 -07:00
gkhmyznikov
446009daba add shell execute for old framework 2026-06-22 16:31:52 -07:00
gkhmyznikov
96d8d70636 Try to fix sessions helper 2026-06-22 15:13:39 -07:00
gkhmyznikov
f13dd6eb61 address the PR review. 2026-06-22 14:53:25 -07:00
gkhmyznikov
5c487a5be3 try to fix the ColorPicker UI pickup 2026-06-22 11:12:36 -07:00
gkhmyznikov
0b0a698fed try to resolve the project startup 2026-06-19 13:48:02 -07:00
gkhmyznikov
858b9ea2db try to fix the settings launch 2026-06-19 10:20:17 -07:00
gkhmyznikov
7ead88631c fix env config 2026-06-18 16:38:42 -07:00
gkhmyznikov
40044ae268 fix the build location 2026-06-18 14:52:18 -07:00
gkhmyznikov
892fe244d7 update framework 2026-06-18 13:14:03 -07:00
gkhmyznikov
0a93c4d179 fix the tests output 2026-06-17 20:22:08 -07:00
gkhmyznikov
eea70294ec fuzzing bump to 10 2026-06-17 17:11:18 -07:00
gkhmyznikov
6183b99020 disable onefuzz 2026-06-17 16:29:45 -07:00
gkhmyznikov
bfd089fc45 workarounds for winappcli install 2026-06-17 14:26:33 -07:00
gkhmyznikov
1208c019c1 Move the audit to before the full build 2026-06-17 13:33:33 -07:00
gkhmyznikov
45da84b377 Fix the audit step 2026-06-17 13:12:51 -07:00
gkhmyznikov
6ddfbdee48 add winappcli installation 2026-06-17 11:26:59 -07:00
gkhmyznikov
715e1bd0dd add plat for later 2026-06-11 13:42:50 -07:00
gkhmyznikov
5740651c63 add more stability 2026-06-11 13:35:59 -07:00
gkhmyznikov
e8df5a7c84 UITest.Next Phase 4: elevation, multi-monitor, status diagnostics
ElevationHelper (new): IsCurrentProcessElevated / IsProcessElevated via OpenProcessToken + TokenElevation. Session.IsElevated surfaces the target process's elevation (null when no PID).

MonitorInfo (new): GetAll / GetPrimary / Count via EnumDisplayMonitors + GetMonitorInfo, returning per-display bounds, work area, and primary flag for multi-monitor utility tests.

Session.Status(): winapp ui status --json for connection diagnostics.

Intentionally deferred (heavy / external-dep, and existing primitives already cover the common cases): perceptual-hash VisualAssert (GetPixelColor + Screenshot cover basic visual checks) and FFmpeg ScreenRecording (failure --capture-screen screenshots cover diagnostics).
2026-06-10 17:18:29 -07:00
gkhmyznikov
ba6612f375 UITest.Next Phase 3: rich interaction (drag, full keyboard/mouse, window helpers)
MouseHelper: add GetMousePosition, LeftDown/Up, RightDown/Up, MiddleDown/Up/Click, DoubleClick, ScrollWheel/Up/Down, and a stepped Drag(from,to). winappcli has no drag/wheel/raw-cursor verbs, so these stay Win32.

KeyboardHelper: extend Key enum with digits, F1-F12, arrows, Home/End/PageUp/PageDown/Insert; add PressKey/ReleaseKey/SendKey/SendKeySequence with extended-key handling for nav keys.

Element: add CLI-first Scroll(direction)/ScrollToEdge (winapp ui scroll) plus Win32 Drag/DragTo/KeyDownAndDrag using the element's search-reported center.

Elements: add Pane/Thumb/Custom/Tab wrappers (drag inherited from Element).

WindowHelper (new): WindowSize enum + SetWindowSize/SetMainWindowSize, GetWindowBounds/Center, GetDisplaySize/GetScreenCenter, GetPixelColor/GetPixelColorHex (GDI) — lets ColorPicker-style tests read on-screen pixels without a hidden XAML peer. IsWindowOpen stays CLI-based via WindowsFinder.

Session: add Attach(module, size) — window-scoped session with optional preset resize.
2026-06-10 17:15:27 -07:00
gkhmyznikov
d7f6f83b71 UITest.Next Phase 2: setup helpers, dev-build path resolution, diagnostics
ModulePaths: expand PowerToysModule enum to 10 modules; resolve exe via POWERTOYS_INSTALL_DIR override -> installed build -> repo dev-build output (x64/ARM64, Debug/Release). useInstallerForTest forces installed layout. Lets tests run against either an installed PowerToys or a local dev build.

EnvironmentConfig: IsInPipeline / UseInstallerForTest / Platform (ported from legacy harness).

SettingsConfigHelper: dependency-free (System.Text.Json.Nodes) ConfigureGlobalModuleSettings + UpdateModuleSettings, writing the per-user settings JSON directly (no Settings.UI.Library coupling).

Session diagnostics (CLI-first): Screenshot element-crop + --capture-screen + non-asserting TryScreenshot; Inspect --interactive/--hide-disabled/--hide-offscreen; InspectAncestors; GetFocused/GetFocusedName.

UITestBase: capture a --capture-screen PNG and attach it on test failure.

SessionHelper: RestartScope (kill -> wait exit -> relaunch + wait window).
2026-06-10 17:07:50 -07:00
gkhmyznikov
294bbcc029 UITest.Next Phase 1: CLI-first element wrappers + Element/Session extensions
Add ComboBox, CheckBox, RadioButton, Slider, TextBlock wrappers (all driven via winapp ui invoke/get-property/get-value/set-value).

Element: add DoubleClick (click --double), ScrollIntoView (scroll-into-view), live properties IsEnabled/IsOffscreen/Displayed/Selected/AutomationId + GetAttribute (get-property), WaitForValue with --contains. Make GetProperty tolerant of non-string/error output and expose EnsureBound to subclasses.

Session: add WaitForElement (wait-for appear).

Fix: TextBox set-value/get-value hardcoded -w <hwnd>, which targeted window 0 under process-scoped (-a) sessions; now uses the session's TargetFlag/TargetValue.
2026-06-10 17:00:52 -07:00
gkhmyznikov
63bd903d2d Rename settings test 2026-06-10 16:11:38 -07:00
gkhmyznikov
7ee6bc6500 Align the settings test with framework 2026-06-10 15:42:39 -07:00
gkhmyznikov
cf3d132a8f move n rename 2026-06-10 14:45:49 -07:00
gkhmyznikov
c37eaf00c3 remove smaller tests 2026-06-10 11:47:06 -07:00
gkhmyznikov
3bc472bcda initial commit 2026-06-10 11:18:17 -07:00
57 changed files with 5837 additions and 75 deletions

View File

@@ -2084,6 +2084,8 @@ wifi
wikimedia
wikipedia
winapi
winapp
winappcli
winappsdk
windir
WINDOWCREATED

View File

@@ -0,0 +1,71 @@
[CmdletBinding()]
Param(
# Target architecture: 'x64' or 'arm64'. Defaults to the pipeline's BuildPlatform variable.
[string]$Platform = $env:BuildPlatform
)
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
# Pinned to the winappcli version the UITestAutomation.Next harness is validated against. Using
# the standalone CLI zip (rather than the MSIX / winget) keeps this working on agents that lack
# the App Installer and avoids MSIX registration entirely.
$Version = 'v0.3.2'
$NormalizedPlatform = if ([string]::IsNullOrWhiteSpace($Platform)) { 'x64' } else { $Platform.ToLowerInvariant() }
switch ($NormalizedPlatform)
{
'arm64'
{
$Asset = 'winappcli-arm64.zip'
$ExpectedHash = 'dfe9d6eb70618665e4adcee989be8ecd076bfd387714a35a5b38597196fed093'
}
default
{
$Asset = 'winappcli-x64.zip'
$ExpectedHash = '231373a4605ce7749172a70534ebab9305f91116e7f68d25cc73051372a6c579'
}
}
$DownloadUrl = "https://github.com/microsoft/winappCli/releases/download/$Version/$Asset"
$ZipPath = Join-Path $env:Temp $Asset
$InstallDir = Join-Path $env:Temp 'winappcli'
Write-Host "Downloading winappcli $Version ($Asset) from $DownloadUrl"
Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath
# Verify the download against the published SHA256 before trusting it.
$Hash = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash
if ($Hash -ne $ExpectedHash)
{
throw "$Asset has unexpected SHA256 hash: $Hash (expected $ExpectedHash)"
}
# Fresh extract each run so a stale copy can't shadow the pinned version.
if (Test-Path $InstallDir)
{
Remove-Item $InstallDir -Recurse -Force
}
Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force
# Clear Mark-of-the-Web in case the agent applied it, so the CLI runs non-interactively.
Get-ChildItem -Path $InstallDir -Recurse | Unblock-File -ErrorAction SilentlyContinue
$winapp = Get-ChildItem -Path $InstallDir -Recurse -Filter 'winapp.exe' | Select-Object -First 1 -ExpandProperty FullName
if (-not $winapp)
{
throw "winapp.exe was not found after extracting $Asset to $InstallDir."
}
Write-Host "winappcli installed at: $winapp"
# The harness (WinappCli.TryResolveExecutable) checks WINAPP_CLI_PATH first; also prepend the
# folder to PATH so any other consumer in later steps resolves winapp.exe too.
Write-Host "##vso[task.setvariable variable=WINAPP_CLI_PATH]$winapp"
Write-Host "##vso[task.prependpath]$(Split-Path -Parent $winapp)"
& $winapp --version
if ($LASTEXITCODE -ne 0)
{
throw "winapp.exe failed to run ('--version' exited with $LASTEXITCODE)."
}

View File

@@ -171,6 +171,11 @@ jobs:
fetchTags: false
fetchDepth: 1
# Checkout to surface a missing import before full build.
- pwsh: |-
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
displayName: Audit shared common props for CSharp projects in src sub-folder
- ${{ if eq(parameters.enableMsBuildCaching, true) }}:
- pwsh: |-
$MSBuildCacheParameters = ""
@@ -464,11 +469,6 @@ jobs:
flattenFolders: True
OverWrite: True
# Check if all projects (located in src sub-folder) import common props
- pwsh: |-
& '.pipelines/verifyCommonProps.ps1' -sourceDir '$(build.sourcesdirectory)\src'
displayName: Audit shared common props for CSharp projects in src sub-folder
# Check if deps.json files don't reference different dll versions.
- pwsh: |-
& '.pipelines/verifyDepsJsonLibraryVersions.ps1' -targetDir '$(build.sourcesdirectory)\$(BuildPlatform)\$(BuildConfiguration)'

View File

@@ -106,12 +106,19 @@ jobs:
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '9.0'
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 ne(parameters.buildSource, 'buildNow') }}:
- task: DownloadPipelineArtifact@2
inputs:
@@ -149,7 +156,124 @@ jobs:
inputs:
displaySettings: 'optimal'
- script: |
dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZones.UITests\FancyZones.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform)
dotnet test $(Build.SourcesDirectory)\src\modules\fancyzones\FancyZonesEditor.UITests\FancyZonesEditor.UITests.csproj --no-build -c $(BuildConfiguration) -p:Platform=$(BuildPlatform)
# 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 console whose stdin blocks, so it stays alive for the run instead of
# 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
$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()

View File

@@ -93,7 +93,7 @@ For complete details, see [Build Guidelines](tools/build/BUILD-GUIDELINES.md).
|------|--------------|-------|
| Unit Tests | Standard dev environment | None |
| UI Tests | WinAppDriver v1.2.1, Developer Mode | Install from [WinAppDriver releases](https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1) |
| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
| Fuzz Tests | OneFuzz, .NET 10 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
### Test discipline

View File

@@ -66,7 +66,10 @@
<LanguageStandard>stdcpplatest</LanguageStandard>
<BuildStlModules>false</BuildStlModules>
<!-- TODO: _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING for compatibility with VS 17.8. Check if we can remove. -->
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<!-- TODO: _SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS for VS 2026 (MSVC 14.51+). The STL turned
<experimental/coroutine> into a hard error (STL1011), and C++/WinRT's base.h still falls back to it when
__cpp_lib_coroutine isn't defined at include time. Remove once C++/WinRT no longer references the experimental header. -->
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<!-- CLR + CFG are not compatible >:{ -->
<ControlFlowGuard Condition="'$(CLRSupport)' == ''">Guard</ControlFlowGuard>
<DebugInformationFormat Condition="'%(ControlFlowGuard)' == 'Guard'">ProgramDatabase</DebugInformationFormat>

View File

@@ -9,11 +9,11 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/common/Common.UI/Common.UI.csproj">
<Project Path="src/common/Common.UI.Controls/Common.UI.Controls.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/common/Common.UI.Controls/Common.UI.Controls.csproj">
<Project Path="src/common/Common.UI/Common.UI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -54,10 +54,14 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/common/UITestAutomation.Next/UITestAutomation.Next.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/common/UnitTests-CommonLib/UnitTests-CommonLib.vcxproj" Id="1a066c63-64b3-45f8-92fe-664e1cce8077" />
<Project Path="src/common/UnitTests-CommonUtils/UnitTests-CommonUtils.vcxproj" Id="8b5cfb38-ccba-40a8-ad7a-89c57b070884" />
<Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" />
<Project Path="src/common/updating/UnitTests/UpdatingUnitTests.vcxproj" Id="a1b2c3d4-e5f6-7890-abcd-ef1234567890" />
<Project Path="src/common/updating/updating.vcxproj" Id="17da04df-e393-4397-9cf0-84dabe11032e" />
<Project Path="src/common/version/version.vcxproj" Id="cc6e41ac-8174-4e8a-8d22-85dd7f4851df" />
</Folder>
<Folder Name="/common/interop/">
@@ -190,6 +194,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/colorPicker/ColorPicker.UITests/ColorPicker.UITests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/CommandPalette/">
<Project Path="src/modules/cmdpal/CmdPalKeyboardService/CmdPalKeyboardService.vcxproj" Id="5f63c743-f6ce-4dba-a200-2b3f8a14e8c2" />
@@ -200,11 +208,11 @@
</Project>
</Folder>
<Folder Name="/modules/CommandPalette/Built-in Extensions/">
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj">
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Actions/Microsoft.CmdPal.Ext.Actions.csproj">
<Project Path="src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.Apps/Microsoft.CmdPal.Ext.Apps.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -715,11 +723,11 @@
</Project>
</Folder>
<Folder Name="/modules/PowerDisplay/">
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -1088,6 +1096,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/settings-ui/Settings.UITests/Settings.UITests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/Solution Items/">
<File Path=".vsconfig" />
@@ -1119,14 +1131,14 @@
<BuildDependency Project="src/modules/fancyzones/editor/FancyZonesEditor/FancyZonesEditor.csproj" />
<BuildDependency Project="src/modules/fancyzones/FancyZonesLib/FancyZonesLib.vcxproj" />
<BuildDependency Project="src/modules/fancyzones/FancyZonesModuleInterface/FancyZonesModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/imageresizer/dll/ImageResizerExt.vcxproj" />
<BuildDependency Project="src/modules/imageresizer/ui/ImageResizerUI.csproj" />
<BuildDependency Project="src/modules/keyboardmanager/dll/KeyboardManager.vcxproj" />
<BuildDependency Project="src/modules/launcher/Microsoft.Launcher/Microsoft.Launcher.vcxproj" />
<BuildDependency Project="src/modules/LightSwitch/LightSwitchModuleInterface/LightSwitchModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/LightSwitch/LightSwitchService/LightSwitchService.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMoveModuleInterface/GrabAndMoveModuleInterface.vcxproj" />
<BuildDependency Project="src/modules/GrabAndMove/GrabAndMove/GrabAndMove.vcxproj" />
<BuildDependency Project="src/modules/powerrename/dll/PowerRenameExt.vcxproj" />
<BuildDependency Project="src/modules/powerrename/lib/PowerRenameLib.vcxproj" />
<BuildDependency Project="src/modules/previewpane/Common/PreviewHandlerCommon.csproj" />

View File

@@ -28,8 +28,8 @@ Create a new test project within your module folder. Ensure the project name fol
### Step 2: Configure the Project
1. Set up a `.NET 8 (Windows)` project
- Note: OneFuzz currently supports only .NET 8 projects. The Fuzz team is working on .NET 9 support.
1. Set up a `.NET 10 (Windows)` project
- Note: OneFuzz's .NET fuzzing is runtime-agnostic (".NET Core targets are preferred") and keys off the build drop directory, so PowerToys fuzz projects target net10 like the rest of the repo. Older guidance pinned .NET 8; that is no longer required.
2. Add the required files to your fuzzing test project:
- Create fuzzing test code
@@ -65,7 +65,7 @@ The `OneFuzzConfig.json` file provides critical information for deploying fuzzin
"targetName": "YourModule",
"jobDependencies": {
"binaries": [
"PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net8.0-windows10.0.19041.0\\**"
"PowerToys\\x64\\Debug\\tests\\YourModule.FuzzTests\\net10.0-windows10.0.26100.0\\**"
]
}
}

View File

@@ -1,11 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Some items may be set in Directory.Build.props in root -->
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<!-- OneFuzz does not currently support testing with .NET 9.
As a temporary workaround, create a .NET 8 project and use file links
to include the code that needs testing. -->
<!-- Fuzz test projects pin their target framework here so it can be managed
independently of the main product TFM (Common.Dotnet.CsWinRT.props). This
was historically .NET 8 because OneFuzz did not support newer runtimes.
Per the current OneFuzz .NET fuzzing docs the service is runtime-agnostic
(".NET Core targets are preferred") and keys off the build drop directory,
so the fuzz projects now track net10 like the rest of the repo. -->
<PropertyGroup>
<TargetFramework>net8.0-windows10.0.26100.0</TargetFramework>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
</PropertyGroup>
<!--

View File

@@ -0,0 +1,49 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Selector used to locate elements via winappcli. winappcli has its own selector grammar
/// (semantic slugs, plain text search) so this type maps onto the CLI's argument shape
/// rather than mimicking Selenium's <c>By</c>.
/// </summary>
public sealed class By
{
public enum Kind
{
/// <summary>Plain-text search against Name or AutomationId (case-insensitive substring).</summary>
Text,
/// <summary>Stable AutomationId, when the developer set one.</summary>
AutomationId,
/// <summary>A semantic slug (e.g., <c>btn-close-d1a0</c>) printed by <c>inspect</c>/<c>search</c>.</summary>
Slug,
}
public Kind Selector { get; }
public string Value { get; }
private By(Kind kind, string value)
{
Selector = kind;
Value = value;
}
/// <summary>Plain-text search; what you'd type into <c>winapp ui search "&lt;text&gt;"</c>.</summary>
public static By Name(string name) => new(Kind.Text, name);
/// <summary>Look up by stable AutomationId (winappcli also accepts these as selectors).</summary>
public static By AccessibilityId(string id) => new(Kind.AutomationId, id);
/// <inheritdoc cref="AccessibilityId(string)"/>
public static By Id(string id) => new(Kind.AutomationId, id);
/// <summary>Direct slug selector (e.g., <c>btn-colorpicker-b415</c>) as printed by inspect/search.</summary>
public static By Slug(string slug) => new(Kind.Slug, slug);
public override string ToString() => $"{Selector}={Value}";
}

View File

@@ -0,0 +1,75 @@
// 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.
using FormsClipboard = System.Windows.Forms.Clipboard;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Clipboard helpers that always execute on an STA thread (<see cref="FormsClipboard"/>
/// requires it). Tolerant — every method swallows clipboard errors and returns a default,
/// so callers can use them in test <c>finally</c> blocks without worrying about masking
/// the real failure.
/// </summary>
public static class ClipboardHelper
{
/// <summary>Return the current clipboard text, or <see cref="string.Empty"/> if none / on error.</summary>
public static string GetText() => RunSTA(() => FormsClipboard.ContainsText() ? FormsClipboard.GetText() : string.Empty) ?? string.Empty;
/// <summary>Clear the clipboard. Returns true on success, false on error.</summary>
public static bool Clear() => RunSTA(() => { FormsClipboard.Clear(); return true; });
/// <summary>Set the clipboard text. Returns true on success, false on error.</summary>
public static bool SetText(string value) => RunSTA(() => { FormsClipboard.SetText(value); return true; });
/// <summary>
/// Poll the clipboard up to <paramref name="timeoutMS"/> for the first non-empty text
/// different from <paramref name="ignoredValue"/>. Returns <see cref="string.Empty"/> on
/// timeout. Use when you've just cleared the clipboard and are waiting for an external
/// app (e.g. ColorPicker on click) to write into it.
/// </summary>
public static string WaitForText(string ignoredValue = "", int timeoutMS = 3_000, int pollIntervalMS = 100)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
var text = GetText();
if (!string.IsNullOrEmpty(text) && text != ignoredValue)
{
return text;
}
Thread.Sleep(pollIntervalMS);
}
return string.Empty;
}
private static T? RunSTA<T>(Func<T> body)
{
T? result = default;
try
{
var thread = new Thread(() =>
{
try
{
result = body();
}
catch
{
// Best effort — clipboard can throw under contention (OpenClipboard failures).
}
});
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
thread.Join(TimeSpan.FromSeconds(5));
}
catch
{
}
return result;
}
}

View File

@@ -0,0 +1,131 @@
// 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.
using System.Runtime.InteropServices;
using System.Windows.Forms;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Display-mode helpers used only by the pipeline path of <see cref="UITestBase"/>: pin the primary
/// display to a known resolution so coordinate-sensitive tests are deterministic in CI, and dump the
/// monitor topology for post-mortem diagnostics. Native because winappcli exposes no display API.
/// </summary>
public static class DisplayHelper
{
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int EnumDisplaySettings(string? lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int ChangeDisplaySettings(ref DEVMODE lpDevMode, int dwflags);
private const int ENUM_CURRENT_SETTINGS = -1;
private const int CDS_TEST = 0x00000002;
private const int CDS_UPDATEREGISTRY = 0x00000001;
private const int DISP_CHANGE_SUCCESSFUL = 0;
private const int DM_PELSWIDTH = 0x00080000;
private const int DM_PELSHEIGHT = 0x00100000;
/// <summary>
/// Pin the primary display to <paramref name="width"/> x <paramref name="height"/>. No-op when
/// already at that resolution. Best-effort — swallows failures because a CI agent may disallow
/// display-mode changes.
/// </summary>
/// <remarks>
/// Unlike the legacy harness (which left <c>dmFields</c> unset), this reads the current mode via
/// <c>EnumDisplaySettings(ENUM_CURRENT_SETTINGS)</c> and sets
/// <c>DM_PELSWIDTH | DM_PELSHEIGHT</c> — the documented, reliable way to request a resolution
/// change.
/// </remarks>
public static void NormalizeResolution(int width, int height)
{
try
{
var primary = Screen.PrimaryScreen;
if (primary is not null && primary.Bounds.Width == width && primary.Bounds.Height == height)
{
return;
}
var devMode = default(DEVMODE);
devMode.DmDeviceName = new string('\0', 32);
devMode.DmFormName = new string('\0', 32);
devMode.DmSize = (short)Marshal.SizeOf<DEVMODE>();
if (EnumDisplaySettings(null, ENUM_CURRENT_SETTINGS, ref devMode) == 0)
{
return;
}
devMode.DmPelsWidth = width;
devMode.DmPelsHeight = height;
devMode.DmFields = DM_PELSWIDTH | DM_PELSHEIGHT;
if (ChangeDisplaySettings(ref devMode, CDS_TEST) == DISP_CHANGE_SUCCESSFUL)
{
ChangeDisplaySettings(ref devMode, CDS_UPDATEREGISTRY);
}
}
catch
{
// Resolution normalization is a CI nicety, not a hard requirement.
}
}
/// <summary>Write the connected-monitor topology to the test log (and console) for diagnostics.</summary>
public static void LogMonitors(TestContext? testContext = null)
{
try
{
foreach (var m in MonitorInfo.GetAll())
{
var line = $"Monitor '{m.DeviceName}': {m.Width}x{m.Height} at ({m.Left},{m.Top}) primary={m.IsPrimary}";
testContext?.WriteLine(line);
Console.WriteLine(line);
}
}
catch
{
// Diagnostics only — never let logging fail a test.
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct DEVMODE
{
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string DmDeviceName;
public short DmSpecVersion;
public short DmDriverVersion;
public short DmSize;
public short DmDriverExtra;
public int DmFields;
public int DmPositionX;
public int DmPositionY;
public int DmDisplayOrientation;
public int DmDisplayFixedOutput;
public short DmColor;
public short DmDuplex;
public short DmYResolution;
public short DmTTOption;
public short DmCollate;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string DmFormName;
public short DmLogPixels;
public int DmBitsPerPel;
public int DmPelsWidth;
public int DmPelsHeight;
public int DmDisplayFlags;
public int DmDisplayFrequency;
public int DmICMMethod;
public int DmICMIntent;
public int DmMediaType;
public int DmDitherType;
public int DmReserved1;
public int DmReserved2;
public int DmPanningWidth;
public int DmPanningHeight;
}
}

View File

@@ -0,0 +1,13 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
public class Button : Element
{
public Button()
{
TargetControlType = "Button";
}
}

View File

@@ -0,0 +1,31 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>CheckBox</c> (UIA ControlType <c>CheckBox</c>). State is read via
/// <c>winapp ui get-property ToggleState</c> and changed via <c>winapp ui invoke</c>.
/// </summary>
public class CheckBox : Element
{
public CheckBox()
{
TargetControlType = "CheckBox";
}
/// <summary>True when UIA <c>ToggleState</c> is <c>On</c> (<c>Indeterminate</c> reads as not-checked).</summary>
public bool IsChecked => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
/// <summary>Flip to <paramref name="value"/> only if currently different.</summary>
public CheckBox SetCheck(bool value = true)
{
if (IsChecked != value)
{
Click();
}
return this;
}
}

View File

@@ -0,0 +1,52 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>ComboBox</c> (UIA ControlType <c>ComboBox</c>). Selection is driven CLI-first:
/// <see cref="Select"/> expands via <c>winapp ui invoke</c> then clicks the chosen item, while
/// editable combo boxes can be set directly with <see cref="SelectByText"/>
/// (<c>winapp ui set-value</c>).
/// </summary>
/// <remarks>
/// The dropdown items live in a popup that the owning process surfaces as a separate window
/// (e.g. Settings' <c>PopupHost</c>). Process-scoped sessions (<see cref="Session.FromProcess"/>)
/// see those items because every search re-resolves via <c>-a</c>; a window-scoped (<c>-w</c>)
/// session may not, in which case prefer <see cref="SelectByText"/>.
/// </remarks>
public class ComboBox : Element
{
public ComboBox()
{
TargetControlType = "ComboBox";
}
/// <summary>Currently selected item text via <c>winapp ui get-value</c> (SelectionPattern fallback).</summary>
public string SelectedText => GetValue();
/// <summary>
/// Expand the combo box (CLI <c>invoke</c> toggles ExpandCollapse) and click the item whose
/// Name matches <paramref name="itemName"/>.
/// </summary>
public ComboBox Select(string itemName, int timeoutMS = 5000)
{
EnsureBound();
Click();
Thread.Sleep(150);
Owner!.Find<Element>(By.Name(itemName), timeoutMS).Click();
return this;
}
/// <summary>
/// Set the combo box value directly via <c>winapp ui set-value</c> (UIA ValuePattern). Works
/// for editable combo boxes; for non-editable combos use <see cref="Select"/>.
/// </summary>
public ComboBox SelectByText(string text)
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, text, Owner!.TargetFlag, Owner!.TargetValue);
return this;
}
}

View File

@@ -0,0 +1,17 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Custom control (UIA ControlType <c>Custom</c>) — used by bespoke surfaces like FancyZones
/// zones and Workspaces canvases. Inherits drag from <see cref="Element"/>.
/// </summary>
public class Custom : Element
{
public Custom()
{
TargetControlType = "Custom";
}
}

View File

@@ -0,0 +1,390 @@
// 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.
using System.Globalization;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Direction for <see cref="Element.Scroll"/> (maps to <c>winapp ui scroll --direction</c>).</summary>
public enum ScrollDirection
{
Up,
Down,
Left,
Right,
}
/// <summary>
/// Reference to a UI element resolved via winappcli. Wraps the resolved <see cref="Selector"/>
/// (slug or text query), the owning <see cref="Session"/>, and the metadata captured at lookup
/// time (control type, class name, name).
/// </summary>
/// <remarks>
/// Element instances are <i>stateless on the wire</i> — every property read and every action
/// shells out to <c>winapp ui …</c>. The cached <see cref="ControlType"/>, <see cref="ClassName"/>,
/// and <see cref="Name"/> are the values seen at <c>Find</c> time; for fresh values, re-find.
/// </remarks>
public class Element
{
internal Session? Owner { get; set; }
/// <summary>The selector winappcli will use to address this element (semantic slug, ID, or text query).</summary>
public string Selector { get; internal set; } = string.Empty;
/// <summary>Cached control type at lookup time (e.g. "Button", "ToggleSwitch").</summary>
public string ControlType { get; internal set; } = string.Empty;
/// <summary>Cached class name at lookup time (e.g. "ToggleSwitch", "TextBlock").</summary>
public string ClassName { get; internal set; } = string.Empty;
/// <summary>Cached Name property at lookup time.</summary>
public string Name { get; internal set; } = string.Empty;
/// <summary>Top-left X (screen pixels) reported by <c>search</c> at lookup time.</summary>
public int X { get; internal set; }
/// <summary>Top-left Y (screen pixels) reported by <c>search</c> at lookup time.</summary>
public int Y { get; internal set; }
/// <summary>Bounding-box width reported by <c>search</c> at lookup time.</summary>
public int Width { get; internal set; }
/// <summary>Bounding-box height reported by <c>search</c> at lookup time.</summary>
public int Height { get; internal set; }
/// <summary>UIA control type that this wrapper subclass expects (e.g. <c>"Button"</c>). Null = match anything.</summary>
protected string? TargetControlType { get; set; }
/// <summary>Optional ClassName filter applied alongside <see cref="TargetControlType"/>.</summary>
protected string? TargetClassName { get; set; }
internal bool MatchesFilter()
{
if (TargetControlType is not null &&
!string.Equals(ControlType, TargetControlType, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (TargetClassName is not null &&
!string.Equals(ClassName, TargetClassName, StringComparison.OrdinalIgnoreCase))
{
return false;
}
return true;
}
/// <summary>
/// Activate the element. winappcli's <c>invoke</c> tries InvokePattern → TogglePattern →
/// SelectionItemPattern → ExpandCollapsePattern in order; <c>rightClick</c> falls back to
/// <c>click --right</c> via real mouse input.
/// </summary>
public virtual void Click(bool rightClick = false, int msPostAction = 200)
{
EnsureBound();
if (rightClick)
{
WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--right");
}
else
{
WinappCli.InvokeAssertSuccess("ui", "invoke", Selector, Owner!.TargetFlag, Owner!.TargetValue);
}
if (msPostAction > 0)
{
Thread.Sleep(msPostAction);
}
}
/// <summary>
/// Mouse-simulation left-click via <c>winapp ui click &lt;slug&gt;</c>. Use for elements that
/// don't expose an InvokePattern (e.g. TextBlocks, ListItems, column headers), where the
/// click is handled by an ancestor's Click handler rather than by the element itself.
/// </summary>
public void MouseClick(int msPostAction = 200)
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue);
if (msPostAction > 0)
{
Thread.Sleep(msPostAction);
}
}
/// <summary>
/// Double-click via <c>winapp ui click &lt;slug&gt; --double</c> (real mouse simulation). Use
/// for controls where a double-click has distinct behavior (list items, headers).
/// </summary>
public void DoubleClick(int msPostAction = 200)
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "click", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--double");
if (msPostAction > 0)
{
Thread.Sleep(msPostAction);
}
}
/// <summary>Scroll this element into the visible area via <c>winapp ui scroll-into-view</c>.</summary>
public void ScrollIntoView()
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "scroll-into-view", Selector, Owner!.TargetFlag, Owner!.TargetValue);
}
/// <summary>
/// Scroll the element's nearest scrollable container in <paramref name="direction"/> via
/// <c>winapp ui scroll</c>. If this element isn't scrollable, the CLI walks up to the nearest
/// scrollable ancestor.
/// </summary>
public void Scroll(ScrollDirection direction)
{
EnsureBound();
WinappCli.InvokeAssertSuccess(
"ui", "scroll", Selector,
Owner!.TargetFlag, Owner!.TargetValue,
"--direction", direction.ToString().ToLowerInvariant());
}
/// <summary>Jump the element's scrollable container to the top or bottom via <c>winapp ui scroll --to</c>.</summary>
public void ScrollToEdge(bool toBottom)
{
EnsureBound();
WinappCli.InvokeAssertSuccess(
"ui", "scroll", Selector,
Owner!.TargetFlag, Owner!.TargetValue,
"--to", toBottom ? "bottom" : "top");
}
/// <summary>
/// Drag this element by a pixel offset using real mouse input (down → stepped move → up).
/// Win32-based: winappcli has no drag verb. Uses the element's center from its search bounds.
/// </summary>
public void Drag(int offsetX, int offsetY, int steps = 10)
{
EnsureBound();
var startX = X + (Width / 2);
var startY = Y + (Height / 2);
MouseHelper.Drag(startX, startY, startX + offsetX, startY + offsetY, steps);
}
/// <summary>Drag this element's center onto <paramref name="target"/>'s center (real mouse input).</summary>
public void DragTo(Element target, int steps = 10)
{
EnsureBound();
var startX = X + (Width / 2);
var startY = Y + (Height / 2);
var endX = target.X + (target.Width / 2);
var endY = target.Y + (target.Height / 2);
MouseHelper.Drag(startX, startY, endX, endY, steps);
}
/// <summary>
/// Hold <paramref name="key"/> down, drag this element's center to absolute screen
/// (<paramref name="targetX"/>, <paramref name="targetY"/>), then release the key. Used for
/// modifier-drag scenarios (FancyZones merge, tab tear-off).
/// </summary>
public void KeyDownAndDrag(Key key, int targetX, int targetY, int steps = 10)
{
EnsureBound();
var startX = X + (Width / 2);
var startY = Y + (Height / 2);
KeyboardHelper.PressKey(key);
try
{
MouseHelper.Drag(startX, startY, targetX, targetY, steps);
}
finally
{
KeyboardHelper.ReleaseKey(key);
}
}
/// <summary>Move keyboard focus to this element.</summary>
public void Focus()
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "focus", Selector, Owner!.TargetFlag, Owner!.TargetValue);
}
/// <summary>
/// Read a single UIA property via <c>winapp ui get-property … --json</c>. Returns the raw string
/// value as winappcli reports it (e.g. <c>"On"</c>/<c>"Off"</c> for <c>ToggleState</c>).
/// </summary>
public string GetProperty(string propertyName)
{
EnsureBound();
var r = WinappCli.Invoke("ui", "get-property", Selector, "-p", propertyName, Owner!.TargetFlag, Owner!.TargetValue, "--json");
if (string.IsNullOrEmpty(r.StdOut))
{
return string.Empty;
}
try
{
using var doc = JsonDocument.Parse(r.StdOut);
if (doc.RootElement.TryGetProperty("properties", out var props) &&
props.TryGetProperty(propertyName, out var v))
{
return JsonValueToString(v);
}
}
catch
{
// Non-JSON / error output (e.g. property unsupported on this element) — treat as empty.
}
return string.Empty;
}
/// <summary>
/// UIA <c>HelpText</c> (from <c>AutomationProperties.HelpText</c>). Used by the Settings UI
/// ShortcutControl to surface the current shortcut as readable text on the EditButton
/// (e.g. <c>"Win + Shift + C"</c>).
/// </summary>
public string HelpText => GetProperty("HelpText");
/// <summary>True when UIA reports the element as enabled (defaults to true when unknown).</summary>
public bool IsEnabled => ParseBool(GetProperty("IsEnabled"), defaultValue: true);
/// <summary>True when UIA reports the element off-screen (defaults to false when unknown).</summary>
public bool IsOffscreen => ParseBool(GetProperty("IsOffscreen"), defaultValue: false);
/// <summary>Convenience inverse of <see cref="IsOffscreen"/> — mirrors the legacy harness's <c>Displayed</c>.</summary>
public bool Displayed => !IsOffscreen;
/// <summary>True when the element is selected (UIA SelectionItemPattern.IsSelected).</summary>
public bool Selected => ParseBool(GetProperty("IsSelected"), defaultValue: false);
/// <summary>The element's UIA AutomationId (empty when it has none).</summary>
public string AutomationId => GetProperty("AutomationId");
/// <summary>
/// Read any UIA property by name via <c>winapp ui get-property</c>. Alias of
/// <see cref="GetProperty"/> kept for parity with the legacy harness's <c>GetAttribute</c>.
/// </summary>
public string GetAttribute(string attributeName) => GetProperty(attributeName);
/// <summary>
/// Read the element's value via <c>winapp ui get-value … --json</c>. winappcli walks
/// TextPattern → ValuePattern → SelectionPattern → Name to find a value, so this returns
/// the rendered text content of TextBlocks (e.g. ColorPicker's <c>ColorTextBlock</c>
/// where <c>AutomationProperties.Name</c> overrides the UIA Name with the color's friendly
/// name, but the actual <c>Text</c> binding holds the HEX value we want).
/// </summary>
public string GetValue()
{
EnsureBound();
var root = WinappCli.InvokeJson("ui", "get-value", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--json");
if (root.TryGetProperty("text", out var t))
{
return t.GetString() ?? string.Empty;
}
return string.Empty;
}
/// <summary>
/// Wait for this element to reach <paramref name="expectedValue"/> on <paramref name="propertyName"/>.
/// Mirrors <c>winapp ui wait-for --property X --value Y -t T</c>; returns true on success, false on timeout.
/// </summary>
public bool WaitForProperty(string propertyName, string expectedValue, int timeoutMS = 5000)
{
EnsureBound();
var r = WinappCli.Invoke(
"ui", "wait-for", Selector,
Owner!.TargetFlag, Owner!.TargetValue,
"--property", propertyName,
"--value", expectedValue,
"-t", timeoutMS.ToString(System.Globalization.CultureInfo.InvariantCulture));
return r.ExitCode == 0;
}
/// <summary>
/// Wait for this element's value (smart fallback: TextPattern → ValuePattern →
/// SelectionPattern → Name) to match <paramref name="expectedValue"/>. When
/// <paramref name="contains"/> is true, matches on substring instead of equality
/// (<c>winapp ui wait-for … --value … --contains</c>). Returns true on match, false on timeout.
/// </summary>
public bool WaitForValue(string expectedValue, bool contains = false, int timeoutMS = 5000)
{
EnsureBound();
var args = new List<string>
{
"ui", "wait-for", Selector,
Owner!.TargetFlag, Owner!.TargetValue,
"--value", expectedValue,
"-t", timeoutMS.ToString(CultureInfo.InvariantCulture),
};
if (contains)
{
args.Add("--contains");
}
return WinappCli.Invoke(args.ToArray()).ExitCode == 0;
}
/// <summary>
/// Wait for any element matching the original selector to disappear from the tree
/// (<c>winapp ui wait-for … --gone</c>).
/// </summary>
public bool WaitForGone(int timeoutMS = 5000)
{
EnsureBound();
var r = WinappCli.Invoke(
"ui", "wait-for", Selector,
Owner!.TargetFlag, Owner!.TargetValue,
"--gone",
"-t", timeoutMS.ToString(System.Globalization.CultureInfo.InvariantCulture));
return r.ExitCode == 0;
}
/// <summary>Find a descendant matching <paramref name="by"/>, scoped under this element via its slug.</summary>
public T Find<T>(By by, int timeoutMS = 5000)
where T : Element, new()
{
EnsureBound();
// winappcli scopes a search beneath an element by passing the parent's selector to inspect.
// For most cases (within the same window) the global search is fine and faster; if you need
// strict scoping under a subtree, use a slug By that prefixes with the parent's slug.
return Owner!.FindUnder<T>(by, timeoutMS);
}
public T Find<T>(string name, int timeoutMS = 5000)
where T : Element, new() => Find<T>(By.Name(name), timeoutMS);
protected void EnsureBound()
{
Assert.IsNotNull(Owner, "Element is not bound to a Session.");
Assert.IsFalse(string.IsNullOrEmpty(Selector), "Element has no selector.");
}
/// <summary>Stringify a JSON property value regardless of kind (string / bool / number).</summary>
private static string JsonValueToString(JsonElement v) => v.ValueKind switch
{
JsonValueKind.String => v.GetString() ?? string.Empty,
JsonValueKind.True => "true",
JsonValueKind.False => "false",
JsonValueKind.Number => v.GetRawText(),
JsonValueKind.Null => string.Empty,
_ => v.GetRawText(),
};
/// <summary>Parse a winappcli boolean-ish property string; falls back to <paramref name="defaultValue"/> when empty.</summary>
private static bool ParseBool(string raw, bool defaultValue)
{
if (string.IsNullOrWhiteSpace(raw))
{
return defaultValue;
}
return raw.Trim().ToLowerInvariant() is "true" or "on" or "1" or "yes";
}
}

View File

@@ -0,0 +1,14 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>WinUI NavigationViewItem surfaces as ControlType.ListItem.</summary>
public class NavigationViewItem : Element
{
public NavigationViewItem()
{
TargetControlType = "ListItem";
}
}

View File

@@ -0,0 +1,14 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>WinUI/WPF <c>Pane</c> (UIA ControlType <c>Pane</c>). Inherits drag from <see cref="Element"/>.</summary>
public class Pane : Element
{
public Pane()
{
TargetControlType = "Pane";
}
}

View File

@@ -0,0 +1,31 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>RadioButton</c> (UIA ControlType <c>RadioButton</c>). Selected state is read via
/// <c>winapp ui get-property IsSelected</c>; selection is performed via <c>winapp ui invoke</c>.
/// </summary>
public class RadioButton : Element
{
public RadioButton()
{
TargetControlType = "RadioButton";
}
/// <summary>True when this radio button is the selected option (UIA SelectionItemPattern.IsSelected).</summary>
public bool IsSelected => Selected;
/// <summary>Select this radio button if it isn't already selected.</summary>
public RadioButton Select()
{
if (!IsSelected)
{
Click();
}
return this;
}
}

View File

@@ -0,0 +1,41 @@
// 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.
using System.Globalization;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI/WPF <c>Slider</c> (UIA ControlType <c>Slider</c>). Reads and writes the value directly
/// through the CLI (<c>winapp ui get-value</c> / <c>set-value</c>, RangeValuePattern) — no
/// arrow-key stepping like the legacy harness.
/// </summary>
public class Slider : Element
{
public Slider()
{
TargetControlType = "Slider";
}
/// <summary>Current value via <c>winapp ui get-value</c>. Returns 0 when it can't be parsed.</summary>
public double Value
{
get
{
var raw = GetValue();
return double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0d;
}
}
/// <summary>Set the value directly via <c>winapp ui set-value</c> (RangeValuePattern).</summary>
public Slider SetValue(double value)
{
EnsureBound();
WinappCli.InvokeAssertSuccess(
"ui", "set-value", Selector,
value.ToString(CultureInfo.InvariantCulture),
Owner!.TargetFlag, Owner!.TargetValue);
return this;
}
}

View File

@@ -0,0 +1,17 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Tab control (UIA ControlType <c>Tab</c>). Inherits drag from <see cref="Element"/> for
/// tab-reorder / tear-off scenarios (see <see cref="Element.KeyDownAndDrag"/>).
/// </summary>
public class Tab : Element
{
public Tab()
{
TargetControlType = "Tab";
}
}

View File

@@ -0,0 +1,20 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Read-only text element (UIA ControlType <c>Text</c>, e.g. a WinUI <c>TextBlock</c>). The
/// rendered text is read via <c>winapp ui get-value</c>, which falls back to the UIA Name.
/// </summary>
public class TextBlock : Element
{
public TextBlock()
{
TargetControlType = "Text";
}
/// <summary>The displayed text via <c>winapp ui get-value</c>.</summary>
public string Text => GetValue();
}

View File

@@ -0,0 +1,46 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Edit / TextBox control. Drives via <c>winapp ui set-value</c> and <c>get-value</c>.</summary>
public class TextBox : Element
{
public TextBox()
{
TargetControlType = "Edit";
}
/// <summary>Set the textbox content via winappcli's <c>set-value</c> (UIA ValuePattern).</summary>
public TextBox SetText(string value)
{
EnsureBound();
WinappCli.InvokeAssertSuccess("ui", "set-value", Selector, value, Owner!.TargetFlag, Owner!.TargetValue);
return this;
}
/// <summary>Current text content via <c>winapp ui get-value</c>.</summary>
public string Value
{
get
{
EnsureBound();
var r = WinappCli.Invoke("ui", "get-value", Selector, Owner!.TargetFlag, Owner!.TargetValue, "--json");
if (!r.Success)
{
return string.Empty;
}
try
{
using var doc = System.Text.Json.JsonDocument.Parse(r.StdOut);
return doc.RootElement.TryGetProperty("text", out var t) ? (t.GetString() ?? string.Empty) : string.Empty;
}
catch
{
return string.Empty;
}
}
}
}

View File

@@ -0,0 +1,17 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Resize/move <c>Thumb</c> (UIA ControlType <c>Thumb</c>), e.g. a splitter or slider handle.
/// Inherits drag from <see cref="Element"/>.
/// </summary>
public class Thumb : Element
{
public Thumb()
{
TargetControlType = "Thumb";
}
}

View File

@@ -0,0 +1,32 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// WinUI <c>ToggleSwitch</c> surfaces as <c>ControlType.Button</c> + <c>ClassName="ToggleSwitch"</c>.
/// Pinning <see cref="Element.TargetClassName"/> avoids picking up sibling Buttons with the same Name
/// (e.g. the module's navigation card on the dashboard).
/// </summary>
public class ToggleSwitch : Button
{
public ToggleSwitch()
{
TargetClassName = "ToggleSwitch";
}
/// <summary>Reads UIA <c>ToggleState</c> via winappcli and compares to <c>"On"</c>.</summary>
public bool IsOn => string.Equals(GetProperty("ToggleState"), "On", StringComparison.OrdinalIgnoreCase);
/// <summary>Flip to <paramref name="value"/> only if currently different.</summary>
public ToggleSwitch Toggle(bool value = true)
{
if (IsOn != value)
{
Click();
}
return this;
}
}

View File

@@ -0,0 +1,13 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
public class Window : Element
{
public Window()
{
TargetControlType = "Window";
}
}

View File

@@ -0,0 +1,71 @@
// 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.
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Win32 helpers to determine whether a process is running elevated (admin). winappcli exposes no
/// elevation query, so this stays native. Useful for tests that must branch on, or assert, the
/// runner's elevation state.
/// </summary>
public static class ElevationHelper
{
private const uint TOKEN_QUERY = 0x0008;
// TOKEN_INFORMATION_CLASS.TokenElevation
private const int TokenElevation = 20;
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out IntPtr tokenHandle);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetTokenInformation(IntPtr tokenHandle, int tokenInformationClass, out uint tokenInformation, uint tokenInformationLength, out uint returnLength);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr hObject);
/// <summary>True when the current test-host process is elevated.</summary>
public static bool IsCurrentProcessElevated()
{
using var p = Process.GetCurrentProcess();
return IsHandleElevated(p.Handle);
}
/// <summary>True when process <paramref name="processId"/> is elevated; null if it can't be queried.</summary>
public static bool? IsProcessElevated(int processId)
{
try
{
using var p = Process.GetProcessById(processId);
return IsHandleElevated(p.Handle);
}
catch
{
return null;
}
}
private static bool IsHandleElevated(IntPtr processHandle)
{
if (!OpenProcessToken(processHandle, TOKEN_QUERY, out var token))
{
return false;
}
try
{
return GetTokenInformation(token, TokenElevation, out var elevated, sizeof(uint), out _) && elevated != 0;
}
finally
{
CloseHandle(token);
}
}
}

View File

@@ -0,0 +1,40 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Centralized access to the environment variables that influence UI-test execution. Mirrors the
/// legacy harness's <c>EnvironmentConfig</c> so module tests can branch on pipeline-vs-local and
/// installed-build-vs-dev-build the same way.
/// </summary>
public static class EnvironmentConfig
{
private static readonly Lazy<bool> InPipeline = new(() =>
!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("platform"))
// TF_BUILD is set to "True" on every Azure DevOps agent and can't be disabled — the
// canonical "running in a pipeline" signal. The test job exposes "platform" only as a
// template parameter (not an env var), so rely on TF_BUILD to enable CI diagnostics.
|| string.Equals(Environment.GetEnvironmentVariable("TF_BUILD"), "true", StringComparison.OrdinalIgnoreCase));
private static readonly Lazy<bool> UseInstaller = new(() =>
{
var raw = Environment.GetEnvironmentVariable("useInstallerForTest")
?? Environment.GetEnvironmentVariable("USEINSTALLERFORTEST");
return !string.IsNullOrEmpty(raw) && bool.TryParse(raw, out var b) && b;
});
private static readonly Lazy<string?> PlatformValue = new(() =>
Environment.GetEnvironmentVariable("platform"));
/// <summary>True when running in CI/CD (the <c>platform</c> env var is set).</summary>
public static bool IsInPipeline => InPipeline.Value;
/// <summary>True when tests should target the installed PowerToys build (<c>useInstallerForTest</c>).</summary>
public static bool UseInstallerForTest => UseInstaller.Value;
/// <summary>Build platform from the <c>platform</c> env var (e.g. <c>x64</c>, <c>arm64</c>), or null locally.</summary>
public static string? Platform => PlatformValue.Value;
}

View File

@@ -0,0 +1,162 @@
# UITestAutomation.Next — Parity & Hardening Plan
Tracks the gaps between the new winappcli-based framework (`UITestAutomation.Next`) and the
legacy WinAppDriver/Selenium framework (`UITestAutomation`), plus the ideal end state. **All gaps
below are now implemented** — see the per-gap **Done** notes and the Status summary. The detailed
sections are kept as the rationale/record.
> Reference points:
> - Legacy base: `src/common/UITestAutomation/UITestBase.cs`
> - New base: `src/common/UITestAutomation.Next/UITestBase.cs`
> - New launch: `src/common/UITestAutomation.Next/SessionHelper.cs`
## Status — implemented
| Gap | Status | Where |
|---|---|---|
| 1 — Clean-slate hygiene | ✅ Done | `UITestBase.PreTestHygiene()` + `virtual StaleProcessNames`; `WindowControl.TryKillProcessByName` |
| 2 — `WindowSize` wired in | ✅ Done | `UITestBase` ctor `size` param + `ApplyWindowSize()` |
| 3 — Module-enablement pre-config | ✅ Done | `UITestBase` ctor `enableModules` param → `ConfigureGlobalModuleSettings` before launch |
| 4 — Scope teardown / restart | ✅ Done | `SessionHelper.launchedByUs` / `StopIfStarted()` / `Restart()`; `UITestBase.RestartScope(...)` |
| 5 — Pipeline diagnostics | ✅ Done (pipeline-gated) | new `ScreenCapture.cs`, `ScreenRecording.cs`, `DisplayHelper.cs`; wired in `UITestBase` |
| 6 — Editor-scope launch audit | ✅ Documented | per-scope launch model in `ModuleConfigData.cs` (`PowerToysModule` doc) |
Framework/test-only change — no product code touched. Harness + both `.Next` consumers
(`ColorPicker.UITests`, `Settings.UITests`) build clean (exit 0).
## Current `.Next` init flow (baseline)
`TestInit` does exactly:
1. Probe `winapp.exe` availability (fail fast with install hint).
2. `new SessionHelper(scope)``Init()` → launch (runner `--open-settings` for Settings scope) and
wait for the first UIA window.
`TestCleanup` captures a single screenshot on failure, then a no-op `Session.Cleanup()`.
> Historical (pre-implementation) baseline. Everything below was present in the legacy harness but
> **missing or unwired** in `.Next` at the time of writing — now implemented (see Status above).
---
## Gap 1 — Clean-slate / window hygiene (HIGH, low risk)
Legacy `TestInit` starts every test from a known desktop state; `.Next` does none of it.
| Behavior | Legacy | `.Next` | Plumbing exists? |
|---|---|---|---|
| Minimize all windows (`Win+M`) | ✅ `KeyboardHelper.SendKeys(Key.Win, Key.M)` | ❌ | ✅ `SendKeys(Key.LWin, Key.M)` |
| Kill stale processes (`PowerToys`, `PowerToys.Settings`, `PowerToys.FancyZonesEditor`) | ✅ `CloseOtherApplications()` | ❌ | ✅ `WindowControl.TryKillProcess` |
| Dismiss popups (`{ESC}`) before launch | ✅ | ❌ | ✅ `KeyboardHelper` |
**Plan:** add a `PreTestHygiene()` step at the top of `TestInit` (before `SessionHelper.Init`):
minimize-all → ESC → kill known stale processes. Make the stale-process list a `virtual` property so
module suites can extend it.
**Done:** `UITestBase.PreTestHygiene()` runs at the top of `TestInit``Win+M``Esc` → kill each
name in the new `virtual StaleProcessNames` property. Uses the new `WindowControl.TryKillProcessByName`
(exact-name match) instead of the Contains-based `TryKillProcess`, so a `PowerToys.*.UITests` test
host is never caught by the "PowerToys" entry.
## Gap 2 — `WindowSize` not wired into the base (HIGH, low risk)
- Legacy ctor: `UITestBase(PowerToysModule scope, WindowSize size, string[]? commandLineArgs)` and applies
`size` during `Session` construction.
- `.Next` already has `WindowHelper.SetWindowSize`, the `WindowSize` enum, and `Session.Attach(size)`
but `UITestBase` has no `size` parameter and never applies one. Every `.Next` test runs at the window's
default size.
- Blocks porting tests that rely on a fixed size, e.g. `src/settings-ui/UITest-Settings/SettingsTests.cs`
(`WindowSize.Large`), Hosts/Workspaces (`WindowSize.Medium`), Peek (`Small_Vertical`).
**Plan:** add `WindowSize size = WindowSize.UnSpecified` to the `UITestBase` ctor; after `Init()` resolves
the window, call `WindowHelper.SetWindowSize(hwnd, size)` when `size != UnSpecified`.
**Done:** `UITestBase` ctor now takes `WindowSize size = UnSpecified` (defaulted). `ApplyWindowSize()`
runs after `Init()` (and after every `RestartScope`) and calls
`WindowHelper.SetWindowSize(new IntPtr(Session.WindowHandle), size)` when set.
## Gap 3 — Module-enablement pre-config not wired in (HIGH, low risk)
- Legacy `StartExe(enableModules)``SettingsConfigHelper.ConfigureGlobalModuleSettings(...)` seeds
`settings.json` **before** launch, so a test starts from a known module on/off state.
- `.Next` ships `SettingsConfigHelper.ConfigureGlobalModuleSettings` but **nothing calls it**. This is the
root of the "test assumes module is ON" fragility class.
**Plan:** add an optional `string[]? enableModules = null` ctor param. When non-null, call
`ConfigureGlobalModuleSettings(enableModules)` in `TestInit` **before** launching the runner. Document that
passing it gives a deterministic module baseline.
**Done:** `UITestBase` ctor takes `string[]? enableModules = null`; `TestInit` calls
`SettingsConfigHelper.ConfigureGlobalModuleSettings(enableModules)` before `SessionHelper.Init` when it's
non-null. The ctor value is also re-applied by `RestartScope()` (unless that call overrides it).
## Gap 4 — No scope teardown on cleanup (MEDIUM, needs design)
- Legacy `TestCleanup``sessionHelper.Cleanup()``ExitScopeExe()` stops what it launched.
- `.Next` `Session.Cleanup()` is a no-op and `EnsureRunning`'s "did I launch it" bool is discarded, so the
base never stops the process it started. (Individual tests like ColorPicker do their own `finally`.)
**Design call needed:** per-test teardown (kill scope process) vs. reuse a long-lived runner across a class.
Recommended: track the "launched-by-me" bool in `SessionHelper`, expose `StopIfStarted()`, and call it from
`TestCleanup` only when the base started the process. Add `RestartScope` convenience equivalent to legacy
`RestartScopeExe`.
**Done:** `SessionHelper` stores `launchedByUs` (set from `EnsureRunning`). `StopIfStarted()` tears down
**only** what we launched — kills the scope process and, for the Settings scope, the runner (exact-name
match); `TestCleanup` calls it. Instance `SessionHelper.Restart()` does kill → relaunch → rebind.
`UITestBase.RestartScope(string[]? enableModules = null)` re-seeds modules (ctor value if null), restarts,
reapplies window size, and returns the new `Session` — the `RestartScopeExe` equivalent.
## Gap 5 — Pipeline diagnostics (MEDIUM/LARGE, CI-only)
Legacy gates these on `EnvironmentConfig.IsInPipeline`:
| Behavior | Legacy | `.Next` | Notes |
|---|---|---|---|
| Normalize resolution to 1920×1080 | ✅ `ChangeDisplayResolution` | ❌ | Port to `MonitorInfo`/native helper |
| Monitor info snapshot | ✅ `GetMonitorInfo()` | ⚠️ `MonitorInfo` exists, not called in init | |
| Screenshot timer (1s cadence) | ✅ `ScreenCapture.TimerCallback` | ❌ | Needs port |
| Screen recording (FFmpeg) | ✅ `ScreenRecording` | ❌ | Needs port |
| On failure attach screenshots + recordings + **log files** | ✅ | ⚠️ single screenshot only | Add log-file + recording attach |
**Plan:** `.Next` `UITestBase` should branch on `EnvironmentConfig.IsInPipeline` and, when true, set up
screenshot timer + recording in `TestInit` and attach artifacts in `TestCleanup`. Treat FFmpeg recording as a
must have.
**Done (pipeline-gated on `EnvironmentConfig.IsInPipeline`):** new files `ScreenCapture.cs` (1s screenshot
timer), `ScreenRecording.cs` (FFmpeg encode), `DisplayHelper.cs` (`NormalizeResolution(1920,1080)` +
`LogMonitors`). `TestInit` normalizes resolution, logs the monitor topology, and starts the timer +
recording before launch; `TestCleanup` stops them and, on failure, attaches screenshots + recordings + the
PowerToys `*.log` files (`AddLogFilesToTestResults`), cleaning recordings on pass. The local (non-pipeline)
path still grabs the single winappcli `--capture-screen` failure shot. *Intentional difference:*
`NormalizeResolution` sets `DM_PELSWIDTH | DM_PELSHEIGHT` on the current mode (the documented, reliable
request) rather than the legacy's fields-unset call.
## Gap 6 — Editor scopes still launch the module exe directly (LOW, follow-up)
After the Settings-scope fix (`PowerToys.exe --open-settings`), editor scopes (Hosts, Workspaces,
CommandPalette, FancyZonesEditor, ScreenRuler) still launch their own exe in `SessionHelper.EnsureRunning`.
That is correct for editors that are meant to run standalone, but confirm each one against how the runner
launches it in production, and document the intended pattern per scope in `ModuleConfigData`.
**Done:** the launch model is now documented on the `PowerToysModule` enum in `ModuleConfigData.cs`
runner-owned Settings (`--open-settings`), the runner itself, standalone editor scopes (FancyZonesEditor,
Hosts, Workspaces, PowerRename, CommandPalette, ScreenRuler), and overlay/background modules (ColorPicker,
LightSwitch) that should be driven through the Settings scope rather than launched standalone.
---
## Suggested sequencing
1.**Phase 1 (quick wins, no API break risk to callers):** Gap 1 hygiene.
2.**Phase 2 (ctor surface):** Gaps 2 + 3 — add `WindowSize` and `enableModules` ctor params (defaulted, so
existing `.Next` tests keep compiling). Unblocks porting legacy Settings/Hosts/Workspaces tests.
3.**Phase 3 (lifecycle):** Gap 4 teardown/restart design + implementation.
4.**Phase 4 (CI):** Gap 5 diagnostics, FFmpeg recording.
5.**Phase 5 (cleanup):** Gap 6 per-scope launch audit + docs.
## Acceptance criteria (per phase)
- Existing `.Next` tests still compile and pass (defaulted params, no behavior change unless opted in).
- New behavior is opt-in or gated (e.g. pipeline-only) so local runs stay fast.
- Each ported behavior matches legacy semantics or documents the intentional difference.
- No product code changes — framework/test only.

View File

@@ -0,0 +1,204 @@
// 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.
using System.Runtime.InteropServices;
using FormsSendKeys = System.Windows.Forms.SendKeys;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Virtual-key constants used by <see cref="KeyboardHelper"/>.</summary>
public enum Key : byte
{
Ctrl = 0x11,
Shift = 0x10,
Alt = 0x12,
LWin = 0x5B,
Tab = 0x09,
Esc = 0x1B,
Enter = 0x0D,
Space = 0x20,
Backspace = 0x08,
Delete = 0x2E,
Insert = 0x2D,
Home = 0x24,
End = 0x23,
PageUp = 0x21,
PageDown = 0x22,
Left = 0x25,
Up = 0x26,
Right = 0x27,
Down = 0x28,
A = 0x41,
B = 0x42,
C = 0x43,
D = 0x44,
E = 0x45,
F = 0x46,
G = 0x47,
H = 0x48,
I = 0x49,
J = 0x4A,
K = 0x4B,
L = 0x4C,
M = 0x4D,
N = 0x4E,
O = 0x4F,
P = 0x50,
Q = 0x51,
R = 0x52,
S = 0x53,
T = 0x54,
U = 0x55,
V = 0x56,
W = 0x57,
X = 0x58,
Y = 0x59,
Z = 0x5A,
Num0 = 0x30,
Num1 = 0x31,
Num2 = 0x32,
Num3 = 0x33,
Num4 = 0x34,
Num5 = 0x35,
Num6 = 0x36,
Num7 = 0x37,
Num8 = 0x38,
Num9 = 0x39,
F1 = 0x70,
F2 = 0x71,
F3 = 0x72,
F4 = 0x73,
F5 = 0x74,
F6 = 0x75,
F7 = 0x76,
F8 = 0x77,
F9 = 0x78,
F10 = 0x79,
F11 = 0x7A,
F12 = 0x7B,
}
/// <summary>
/// Global keyboard input. Uses the same hybrid strategy as the legacy harness because pure
/// <c>keybd_event</c> injection doesn't reliably trigger <c>RegisterHotKey</c>-registered global
/// hotkeys for the PowerToys runner: hold LWIN down via <c>keybd_event</c>, then send the
/// remaining chord via <see cref="System.Windows.Forms.SendKeys.SendWait"/> which uses
/// SendInput with proper modifier tracking, then release LWIN.
/// </summary>
public static class KeyboardHelper
{
[DllImport("user32.dll", SetLastError = true)]
#pragma warning disable SA1300 // win32 API name
private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
#pragma warning restore SA1300
private const uint KEYEVENTF_KEYUP = 0x2;
private const uint KEYEVENTF_EXTENDEDKEY = 0x1;
private const byte VK_LWIN = 0x5B;
/// <summary>
/// Send a chord of keys. If the chord contains <see cref="Key.LWin"/>, LWIN is held via
/// <c>keybd_event</c> while the remaining keys are sent via <see cref="FormsSendKeys.SendWait"/>.
/// Otherwise everything goes through SendKeys.SendWait (the modifier-aware Windows path).
/// </summary>
public static void SendKeys(params Key[] keys)
{
bool winDown = false;
var chord = new System.Text.StringBuilder();
foreach (var k in keys)
{
switch (k)
{
case Key.LWin:
keybd_event(VK_LWIN, 0, 0, UIntPtr.Zero);
winDown = true;
break;
case Key.Ctrl: chord.Append('^'); break;
case Key.Shift: chord.Append('+'); break;
case Key.Alt: chord.Append('%'); break;
case Key.Esc: chord.Append("{ESC}"); break;
case Key.Enter: chord.Append("{ENTER}"); break;
case Key.Tab: chord.Append("{TAB}"); break;
case Key.Space: chord.Append(' '); break;
case Key.Backspace: chord.Append("{BACKSPACE}"); break;
case Key.Delete: chord.Append("{DELETE}"); break;
case Key.Insert: chord.Append("{INSERT}"); break;
case Key.Home: chord.Append("{HOME}"); break;
case Key.End: chord.Append("{END}"); break;
case Key.PageUp: chord.Append("{PGUP}"); break;
case Key.PageDown: chord.Append("{PGDN}"); break;
case Key.Up: chord.Append("{UP}"); break;
case Key.Down: chord.Append("{DOWN}"); break;
case Key.Left: chord.Append("{LEFT}"); break;
case Key.Right: chord.Append("{RIGHT}"); break;
case Key.F1: chord.Append("{F1}"); break;
case Key.F2: chord.Append("{F2}"); break;
case Key.F3: chord.Append("{F3}"); break;
case Key.F4: chord.Append("{F4}"); break;
case Key.F5: chord.Append("{F5}"); break;
case Key.F6: chord.Append("{F6}"); break;
case Key.F7: chord.Append("{F7}"); break;
case Key.F8: chord.Append("{F8}"); break;
case Key.F9: chord.Append("{F9}"); break;
case Key.F10: chord.Append("{F10}"); break;
case Key.F11: chord.Append("{F11}"); break;
case Key.F12: chord.Append("{F12}"); break;
default:
// Letter / digit keys map to their lowercase character for SendKeys.
chord.Append(((char)k).ToString().ToLowerInvariant());
break;
}
}
try
{
if (chord.Length > 0)
{
FormsSendKeys.SendWait(chord.ToString());
}
}
finally
{
if (winDown)
{
keybd_event(VK_LWIN, 0, KEYEVENTF_KEYUP, UIntPtr.Zero);
}
}
}
/// <summary>Press (and hold) a key via <c>keybd_event</c>. Pair with <see cref="ReleaseKey"/>.</summary>
public static void PressKey(Key key) =>
keybd_event((byte)key, 0, IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u, UIntPtr.Zero);
/// <summary>Release a key previously pressed with <see cref="PressKey"/>.</summary>
public static void ReleaseKey(Key key) =>
keybd_event((byte)key, 0, KEYEVENTF_KEYUP | (IsExtended(key) ? KEYEVENTF_EXTENDEDKEY : 0u), UIntPtr.Zero);
/// <summary>Press + release a single key.</summary>
public static void SendKey(Key key)
{
PressKey(key);
Thread.Sleep(20);
ReleaseKey(key);
}
/// <summary>Press + release each key in order (independent taps, not a held chord).</summary>
public static void SendKeySequence(params Key[] keys)
{
foreach (var k in keys)
{
SendKey(k);
Thread.Sleep(20);
}
}
private static bool IsExtended(Key key) => key is
Key.Left or Key.Up or Key.Right or Key.Down or
Key.Home or Key.End or Key.PageUp or Key.PageDown or
Key.Insert or Key.Delete;
}

View File

@@ -0,0 +1,207 @@
// 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.
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Modules of PowerToys that a <see cref="UITestBase"/> can target.
/// </summary>
/// <remarks>
/// <para>
/// <b>Launch model per scope</b> (see <see cref="SessionHelper.EnsureRunning"/>):
/// </para>
/// <list type="bullet">
/// <item><description><see cref="PowerToysSettings"/> — runner-owned. Launched via
/// <c>PowerToys.exe --open-settings</c> so the runner owns module toggles and activation hotkeys.
/// This is the scope to use when a test drives a utility <i>through the Settings UI</i>
/// (e.g. <c>ColorPicker.UITests</c>), because a standalone module exe has no runner behind it.</description></item>
/// <item><description><see cref="Runner"/> — launches <c>PowerToys.exe</c> directly (the tray/runner host).</description></item>
/// <item><description><b>Editor scopes</b> (<see cref="FancyZonesEditor"/>, <see cref="Hosts"/>,
/// <see cref="Workspaces"/>, <see cref="PowerRename"/>, <see cref="CommandPalette"/>,
/// <see cref="ScreenRuler"/>) — launch their own exe standalone. These are designed to run as
/// self-contained editor windows, so binding directly to the editor's window is correct.</description></item>
/// <item><description><see cref="ColorPicker"/>, <see cref="LightSwitch"/> — overlay/background
/// modules that are <i>not</i> meant to be launched standalone by a test; drive them through the
/// <see cref="PowerToysSettings"/> scope (toggle + activation hotkey) instead. The entries exist
/// so window/process discovery can still resolve them once the runner spawns them.</description></item>
/// </list>
public enum PowerToysModule
{
PowerToysSettings,
Runner,
ColorPicker,
FancyZonesEditor,
Hosts,
Workspaces,
PowerRename,
CommandPalette,
ScreenRuler,
LightSwitch,
}
/// <summary>
/// Resolves executable paths, process names, and window titles for a <see cref="PowerToysModule"/>.
/// </summary>
/// <remarks>
/// Path resolution order: an explicit <c>POWERTOYS_INSTALL_DIR</c> override; then, when
/// <c>useInstallerForTest</c> is set, the installed build (Program Files / LocalAppData); otherwise
/// the build under test — located by walking up from the test assembly to the build-output root that
/// holds the exe (locally <c>&lt;root&gt;\&lt;plat&gt;\&lt;cfg&gt;</c>, in CI the downloaded build artifact) —
/// and finally the installed path as a last resort. This lets the same tests run against an installed
/// PowerToys or a dev / CI-artifact build without any environment configuration.
/// </remarks>
internal static class ModulePaths
{
private sealed record ModuleMeta(string ExeName, string? SubDir, string ProcessName, string WindowTitle);
private static readonly IReadOnlyDictionary<PowerToysModule, ModuleMeta> Meta =
new Dictionary<PowerToysModule, ModuleMeta>
{
[PowerToysModule.PowerToysSettings] = new("PowerToys.Settings.exe", "WinUI3Apps", "PowerToys.Settings", "PowerToys Settings"),
[PowerToysModule.Runner] = new("PowerToys.exe", null, "PowerToys", "PowerToys"),
[PowerToysModule.ColorPicker] = new("PowerToys.ColorPickerUI.exe", null, "PowerToys.ColorPickerUI", "PowerToys.ColorPickerUI"),
[PowerToysModule.FancyZonesEditor] = new("PowerToys.FancyZonesEditor.exe", null, "PowerToys.FancyZonesEditor", "FancyZones Layout"),
[PowerToysModule.Hosts] = new("PowerToys.Hosts.exe", "WinUI3Apps", "PowerToys.Hosts", "Hosts File Editor"),
[PowerToysModule.Workspaces] = new("PowerToys.WorkspacesEditor.exe", null, "PowerToys.WorkspacesEditor", "Workspaces Editor"),
[PowerToysModule.PowerRename] = new("PowerToys.PowerRename.exe", "WinUI3Apps", "PowerToys.PowerRename", "PowerRename"),
[PowerToysModule.CommandPalette] = new("Microsoft.CmdPal.UI.exe", "WinUI3Apps\\CmdPal", "Microsoft.CmdPal.UI", "PowerToys Command Palette"),
[PowerToysModule.ScreenRuler] = new("PowerToys.MeasureToolUI.exe", "WinUI3Apps", "PowerToys.MeasureToolUI", "PowerToys.ScreenRuler"),
[PowerToysModule.LightSwitch] = new("PowerToys.LightSwitch.exe", "LightSwitchService", "PowerToys.LightSwitch", "PowerToys.LightSwitch"),
};
private static readonly Lazy<string> InstalledRoot = new(ResolveInstalledRoot);
private static readonly Lazy<string?> RepoRoot = new(FindRepoRoot);
public static string ExePathFor(PowerToysModule module)
{
var meta = Meta[module];
// 1. Explicit override wins (CI can point at any layout).
var overrideDir = Environment.GetEnvironmentVariable("POWERTOYS_INSTALL_DIR");
if (!string.IsNullOrEmpty(overrideDir))
{
var overridePath = Compose(overrideDir, meta);
if (File.Exists(overridePath))
{
return overridePath;
}
}
var installed = Compose(InstalledRoot.Value, meta);
// 2. Installer mode forces the installed layout.
if (EnvironmentConfig.UseInstallerForTest)
{
return installed;
}
// 3. Dev / CI-artifact mode: the build output that holds the exe is an ancestor of the test
// assembly. Prefer it so tests drive the build under test, not a stray machine install.
if (TryComposeDevBuild(meta, out var dev))
{
return dev;
}
// 4. Last resort: an installed build if present (returns the installed path either way so a
// launch failure names a concrete location).
return installed;
}
/// <summary>Process name as winappcli's <c>-a</c> flag accepts it (case-insensitive substring).</summary>
public static string ProcessNameFor(PowerToysModule module) => Meta[module].ProcessName;
/// <summary>Expected window title substring; used to pick the right HWND when a module has several windows.</summary>
public static string MainWindowTitleFor(PowerToysModule module) => module switch
{
// The runner has no user-facing main window title to pin.
PowerToysModule.Runner => string.Empty,
_ => Meta[module].WindowTitle,
};
private static string Compose(string root, ModuleMeta meta) =>
string.IsNullOrEmpty(meta.SubDir)
? Path.Combine(root, meta.ExeName)
: Path.Combine(root, meta.SubDir, meta.ExeName);
private static bool TryComposeDevBuild(ModuleMeta meta, out string path)
{
path = string.Empty;
// The build-output root that holds PowerToys.exe (and module subdirs like WinUI3Apps) is an
// ancestor of the test assembly's bin folder — both locally
// (<root>\<plat>\<cfg>\tests\<proj>\<tfm>\) and in CI (the downloaded build artifact, which
// can nest <plat>\<cfg> more than once). Walk up and return the first ancestor that actually
// contains the requested exe. Mirrors the legacy harness's "<assembly>\..\..\..\<exe>"
// convention without hard-coding the depth.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
var candidate = Compose(dir.FullName, meta);
if (File.Exists(candidate))
{
path = candidate;
return true;
}
dir = dir.Parent;
}
// Fallback: repo root + conventional <plat>\<cfg> output, for the rare case the assembly
// isn't located under the build tree.
var root = RepoRoot.Value;
if (!string.IsNullOrEmpty(root))
{
foreach (var platform in new[] { "x64", "ARM64" })
{
foreach (var config in new[] { "Debug", "Release" })
{
var candidate = Compose(Path.Combine(root, platform, config), meta);
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
}
}
return false;
}
private static string ResolveInstalledRoot()
{
string[] candidates =
{
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "PowerToys"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "PowerToys"),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PowerToys"),
};
foreach (var candidate in candidates)
{
if (File.Exists(Path.Combine(candidate, "PowerToys.exe")))
{
return candidate;
}
}
return candidates[0];
}
private static string? FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (File.Exists(Path.Combine(dir.FullName, "PowerToys.slnx")))
{
return dir.FullName;
}
dir = dir.Parent;
}
return null;
}
}

View File

@@ -0,0 +1,104 @@
// 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.
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Multi-monitor enumeration via Win32 (<c>EnumDisplayMonitors</c> / <c>GetMonitorInfo</c>).
/// winappcli exposes no display topology, so this stays native — useful for multi-monitor
/// utilities (FancyZones, Mouse Utilities, Mouse Without Borders).
/// </summary>
public static class MonitorInfo
{
/// <summary>One physical display, in virtual-screen pixel coordinates.</summary>
public sealed record Monitor(
string DeviceName,
int Left,
int Top,
int Right,
int Bottom,
int WorkLeft,
int WorkTop,
int WorkRight,
int WorkBottom,
bool IsPrimary)
{
/// <summary>Full monitor width in pixels.</summary>
public int Width => Right - Left;
/// <summary>Full monitor height in pixels.</summary>
public int Height => Bottom - Top;
}
private const uint MONITORINFOF_PRIMARY = 0x1;
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdc, ref RECT lprcMonitor, IntPtr dwData);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
/// <summary>All connected displays, in enumeration order.</summary>
public static IReadOnlyList<Monitor> GetAll()
{
var list = new List<Monitor>();
EnumDisplayMonitors(IntPtr.Zero, IntPtr.Zero, EnumCallback, IntPtr.Zero);
return list;
bool EnumCallback(IntPtr hMonitor, IntPtr hdc, ref RECT lprcMonitor, IntPtr dwData)
{
var mi = new MONITORINFOEX { CbSize = Marshal.SizeOf<MONITORINFOEX>() };
if (GetMonitorInfo(hMonitor, ref mi))
{
list.Add(new Monitor(
mi.SzDevice,
mi.RcMonitor.Left,
mi.RcMonitor.Top,
mi.RcMonitor.Right,
mi.RcMonitor.Bottom,
mi.RcWork.Left,
mi.RcWork.Top,
mi.RcWork.Right,
mi.RcWork.Bottom,
(mi.DwFlags & MONITORINFOF_PRIMARY) != 0));
}
return true;
}
}
/// <summary>The primary display, or null if none reported.</summary>
public static Monitor? GetPrimary() => GetAll().FirstOrDefault(m => m.IsPrimary);
/// <summary>Number of connected displays.</summary>
public static int Count => GetAll().Count;
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct MONITORINFOEX
{
public int CbSize;
public RECT RcMonitor;
public RECT RcWork;
public uint DwFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string SzDevice;
}
}

View File

@@ -0,0 +1,152 @@
// 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.
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Global mouse input via Win32 <c>SetCursorPos</c> and <c>mouse_event</c>. Required for
/// scenarios like clicking inside the ColorPicker overlay, which is a transparent window that
/// can't be targeted via UIA / <c>winapp ui click</c>.
/// </summary>
public static class MouseHelper
{
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
private const uint MOUSEEVENTF_LEFTDOWN = 0x02;
private const uint MOUSEEVENTF_LEFTUP = 0x04;
private const uint MOUSEEVENTF_RIGHTDOWN = 0x08;
private const uint MOUSEEVENTF_RIGHTUP = 0x10;
private const uint MOUSEEVENTF_MIDDLEDOWN = 0x20;
private const uint MOUSEEVENTF_MIDDLEUP = 0x40;
private const uint MOUSEEVENTF_WHEEL = 0x0800;
private const int ClickDelayMs = 60;
private const int WheelTick = 120;
[DllImport("user32.dll")]
private static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out POINT lpPoint);
[DllImport("user32.dll")]
#pragma warning disable SA1300 // win32 API name
private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
#pragma warning restore SA1300
/// <summary>Move the OS cursor to absolute screen coordinates.</summary>
public static void MoveTo(int x, int y) => SetCursorPos(x, y);
/// <summary>Current cursor position in screen pixels.</summary>
public static (int X, int Y) GetMousePosition()
{
GetCursorPos(out var p);
return (p.X, p.Y);
}
/// <summary>Press the left mouse button down at the current position.</summary>
public static void LeftDown() => mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero);
/// <summary>Release the left mouse button.</summary>
public static void LeftUp() => mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero);
/// <summary>Press the right mouse button down at the current position.</summary>
public static void RightDown() => mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, UIntPtr.Zero);
/// <summary>Release the right mouse button.</summary>
public static void RightUp() => mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, UIntPtr.Zero);
/// <summary>Press the middle mouse button down at the current position.</summary>
public static void MiddleDown() => mouse_event(MOUSEEVENTF_MIDDLEDOWN, 0, 0, 0, UIntPtr.Zero);
/// <summary>Release the middle mouse button.</summary>
public static void MiddleUp() => mouse_event(MOUSEEVENTF_MIDDLEUP, 0, 0, 0, UIntPtr.Zero);
/// <summary>Press + release left mouse button at the current cursor position.</summary>
public static void LeftClick()
{
LeftDown();
Thread.Sleep(ClickDelayMs);
LeftUp();
}
/// <summary>Move cursor to (x,y) and left-click.</summary>
public static void LeftClickAt(int x, int y)
{
MoveTo(x, y);
Thread.Sleep(40);
LeftClick();
}
/// <summary>Press + release right mouse button at the current cursor position.</summary>
public static void RightClick()
{
RightDown();
Thread.Sleep(ClickDelayMs);
RightUp();
}
/// <summary>Press + release middle mouse button at the current cursor position.</summary>
public static void MiddleClick()
{
MiddleDown();
Thread.Sleep(ClickDelayMs);
MiddleUp();
}
/// <summary>Left double-click at the current cursor position.</summary>
public static void DoubleClick()
{
LeftClick();
Thread.Sleep(ClickDelayMs);
LeftClick();
}
/// <summary>Scroll the wheel by a raw amount (positive = up, negative = down; one tick = 120).</summary>
public static void ScrollWheel(int amount) => mouse_event(MOUSEEVENTF_WHEEL, 0, 0, (uint)amount, UIntPtr.Zero);
/// <summary>Scroll the wheel up by one tick.</summary>
public static void ScrollUp() => ScrollWheel(WheelTick);
/// <summary>Scroll the wheel down by one tick.</summary>
public static void ScrollDown() => ScrollWheel(-WheelTick);
/// <summary>
/// Drag from one absolute screen point to another with real mouse input: move → left-down →
/// stepped move → left-up. winappcli has no drag verb, so this stays Win32. Coordinates are
/// physical screen pixels (matching <c>winapp ui search</c> bounds).
/// </summary>
public static void Drag(int fromX, int fromY, int toX, int toY, int steps = 10)
{
if (steps < 1)
{
steps = 1;
}
MoveTo(fromX, fromY);
Thread.Sleep(40);
LeftDown();
Thread.Sleep(40);
var dx = (double)(toX - fromX) / steps;
var dy = (double)(toY - fromY) / steps;
for (var i = 1; i <= steps; i++)
{
MoveTo(fromX + (int)Math.Round(dx * i), fromY + (int)Math.Round(dy * i));
Thread.Sleep(15);
}
MoveTo(toX, toY);
Thread.Sleep(40);
LeftUp();
}
}

View File

@@ -0,0 +1,128 @@
// 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.
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Captures the full desktop (including the mouse cursor) to a PNG. Used only by the pipeline path
/// of <see cref="UITestBase"/>, which fires <see cref="TimerCallback"/> on a one-second timer so a
/// failed CI run carries a frame-by-frame trail. Ported from the legacy harness — winappcli has no
/// equivalent full-desktop capture, so this stays native (GDI).
/// </summary>
internal static class ScreenCapture
{
[DllImport("user32.dll")]
private static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("gdi32.dll")]
private static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
[DllImport("user32.dll")]
private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorInfo(out CURSORINFO pci);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags);
private const int CURSORSHOWING = 0x00000001;
private const int DESKTOPHORZRES = 118;
private const int DESKTOPVERTRES = 117;
private const int DINORMAL = 0x0003;
/// <summary>A point with X and Y coordinates (Win32 <c>POINT</c>).</summary>
[StructLayout(LayoutKind.Sequential)]
public struct POINT
{
public int X;
public int Y;
}
/// <summary>Cursor state/handle/position (Win32 <c>CURSORINFO</c>).</summary>
[StructLayout(LayoutKind.Sequential)]
public struct CURSORINFO
{
public int CbSize;
public int Flags;
public IntPtr HCursor;
public POINT PTScreenPos;
}
/// <summary>
/// Timer callback: capture one screenshot into the directory passed as <paramref name="state"/>.
/// Tolerant — a capture failure must never crash the timer thread.
/// </summary>
public static void TimerCallback(object? state)
{
try
{
if (state is string directory)
{
CaptureScreenshot(directory);
}
}
catch
{
// Best-effort capture; swallow so the timer keeps firing.
}
}
/// <summary>
/// Capture the full (primary) desktop to <paramref name="filePath"/> as a PNG. Pure GDI, so
/// unlike winappcli's <c>--capture-screen</c> (which needs a live target window) this works even
/// when the test window was already closed or never appeared — the reliable failure-screenshot
/// path. Returns false on any failure.
/// </summary>
public static bool TryCaptureDesktop(string filePath)
{
try
{
CaptureScreenWithMouse(filePath);
return File.Exists(filePath);
}
catch
{
return false;
}
}
private static void CaptureScreenshot(string directory)
{
var filePath = Path.Combine(directory, $"screenshot_{DateTime.Now:yyyyMMdd_HHmmssfff}.png");
CaptureScreenWithMouse(filePath);
}
private static void CaptureScreenWithMouse(string filePath)
{
var hdc = GetDC(IntPtr.Zero);
var screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES);
var screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES);
ReleaseDC(IntPtr.Zero, hdc);
var bounds = new Rectangle(0, 0, screenWidth, screenHeight);
using var bitmap = new Bitmap(bounds.Width, bounds.Height);
using (var g = Graphics.FromImage(bitmap))
{
g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size);
var cursorInfo = default(CURSORINFO);
cursorInfo.CbSize = Marshal.SizeOf<CURSORINFO>();
if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING)
{
var hdcDest = g.GetHdc();
DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL);
g.ReleaseHdc(hdcDest);
}
}
bitmap.Save(filePath, ImageFormat.Png);
}
}

View File

@@ -0,0 +1,340 @@
// 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.
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Records the desktop to an MP4 during a UI test by sampling GDI frames and encoding them with
/// FFmpeg. Used only by the pipeline path of <see cref="UITestBase"/>. If FFmpeg isn't on PATH (or
/// in a few well-known locations) recording silently disables itself — screenshots still cover the
/// failure. Ported from the legacy harness.
/// </summary>
internal sealed class ScreenRecording : IDisposable
{
[DllImport("user32.dll")]
private static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("gdi32.dll")]
private static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
[DllImport("user32.dll")]
private static extern IntPtr ReleaseDC(IntPtr hWnd, IntPtr hDC);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorInfo(out ScreenCapture.CURSORINFO pci);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool DrawIconEx(IntPtr hdc, int x, int y, IntPtr hIcon, int cx, int cy, int istepIfAniCur, IntPtr hbrFlickerFreeDraw, int diFlags);
private const int CURSORSHOWING = 0x00000001;
private const int DESKTOPHORZRES = 118;
private const int DESKTOPVERTRES = 117;
private const int DINORMAL = 0x0003;
private const int TargetFps = 15; // Balance of quality and size.
private readonly string outputDirectory;
private readonly string framesDirectory;
private readonly string outputFilePath;
private readonly List<string> capturedFrames;
private readonly SemaphoreSlim recordingLock = new(1, 1);
private readonly Stopwatch recordingStopwatch = new();
private readonly string? ffmpegPath;
private CancellationTokenSource? recordingCancellation;
private Task? recordingTask;
private bool isRecording;
private int frameCount;
public ScreenRecording(string outputDirectory)
{
this.outputDirectory = outputDirectory;
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
framesDirectory = Path.Combine(outputDirectory, $"frames_{timestamp}");
outputFilePath = Path.Combine(outputDirectory, $"recording_{timestamp}.mp4");
capturedFrames = new List<string>();
frameCount = 0;
ffmpegPath = FindFfmpeg();
if (ffmpegPath is null)
{
Console.WriteLine("FFmpeg not found. Screen recording will be disabled.");
Console.WriteLine("To enable video recording, install FFmpeg: https://ffmpeg.org/download.html");
}
}
/// <summary>True when FFmpeg was located, so recording can actually produce an MP4.</summary>
public bool IsAvailable => ffmpegPath is not null;
/// <summary>Path the encoded MP4 will be written to.</summary>
public string OutputFilePath => outputFilePath;
/// <summary>Directory containing the recording output.</summary>
public string OutputDirectory => outputDirectory;
/// <summary>Start sampling frames on a background task.</summary>
public async Task StartRecordingAsync()
{
await recordingLock.WaitAsync();
try
{
if (isRecording || !IsAvailable)
{
return;
}
Directory.CreateDirectory(framesDirectory);
recordingCancellation = new CancellationTokenSource();
isRecording = true;
recordingStopwatch.Start();
recordingTask = Task.Run(() => RecordFrames(recordingCancellation.Token));
Console.WriteLine($"Started screen recording at {TargetFps} FPS");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to start recording: {ex.Message}");
isRecording = false;
}
finally
{
recordingLock.Release();
}
}
/// <summary>Stop sampling and encode the captured frames to an MP4.</summary>
public async Task StopRecordingAsync()
{
await recordingLock.WaitAsync();
try
{
if (!isRecording || recordingCancellation is null)
{
return;
}
recordingCancellation.Cancel();
if (recordingTask is not null)
{
await recordingTask;
}
recordingStopwatch.Stop();
isRecording = false;
var duration = recordingStopwatch.Elapsed.TotalSeconds;
Console.WriteLine($"Recording stopped. Captured {capturedFrames.Count} frames in {duration:F2} seconds");
await EncodeToVideoAsync();
}
catch (Exception ex)
{
Console.WriteLine($"Error stopping recording: {ex.Message}");
}
finally
{
Cleanup();
recordingLock.Release();
}
}
public void Dispose()
{
if (isRecording)
{
StopRecordingAsync().GetAwaiter().GetResult();
}
Cleanup();
recordingLock.Dispose();
GC.SuppressFinalize(this);
}
private void RecordFrames(CancellationToken cancellationToken)
{
try
{
var frameInterval = 1000 / TargetFps;
var frameTimer = Stopwatch.StartNew();
while (!cancellationToken.IsCancellationRequested)
{
var frameStart = frameTimer.ElapsedMilliseconds;
try
{
CaptureFrame();
}
catch (Exception ex)
{
Console.WriteLine($"Error capturing frame: {ex.Message}");
}
var frameTime = frameTimer.ElapsedMilliseconds - frameStart;
var sleepTime = Math.Max(0, frameInterval - (int)frameTime);
if (sleepTime > 0)
{
Thread.Sleep(sleepTime);
}
}
}
catch (OperationCanceledException)
{
// Expected when stopping.
}
catch (Exception ex)
{
Console.WriteLine($"Error during recording: {ex.Message}");
}
}
private void CaptureFrame()
{
var hdc = GetDC(IntPtr.Zero);
var screenWidth = GetDeviceCaps(hdc, DESKTOPHORZRES);
var screenHeight = GetDeviceCaps(hdc, DESKTOPVERTRES);
ReleaseDC(IntPtr.Zero, hdc);
var bounds = new Rectangle(0, 0, screenWidth, screenHeight);
using var bitmap = new Bitmap(bounds.Width, bounds.Height, PixelFormat.Format24bppRgb);
using (var g = Graphics.FromImage(bitmap))
{
g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size);
var cursorInfo = default(ScreenCapture.CURSORINFO);
cursorInfo.CbSize = Marshal.SizeOf<ScreenCapture.CURSORINFO>();
if (GetCursorInfo(out cursorInfo) && cursorInfo.Flags == CURSORSHOWING)
{
var hdcDest = g.GetHdc();
DrawIconEx(hdcDest, cursorInfo.PTScreenPos.X, cursorInfo.PTScreenPos.Y, cursorInfo.HCursor, 0, 0, 0, IntPtr.Zero, DINORMAL);
g.ReleaseHdc(hdcDest);
}
}
var framePath = Path.Combine(framesDirectory, $"frame_{frameCount:D6}.jpg");
bitmap.Save(framePath, ImageFormat.Jpeg);
capturedFrames.Add(framePath);
frameCount++;
}
private async Task EncodeToVideoAsync()
{
if (capturedFrames.Count == 0)
{
Console.WriteLine("No frames captured");
return;
}
try
{
var inputPattern = Path.Combine(framesDirectory, "frame_%06d.jpg");
// -y overwrite, -nostdin no interaction, -loglevel error quiet, -stats progress.
var args = $"-y -nostdin -loglevel error -stats -framerate {TargetFps} -i \"{inputPattern}\" -c:v libx264 -pix_fmt yuv420p -crf 23 \"{outputFilePath}\"";
Console.WriteLine($"Encoding {capturedFrames.Count} frames to video...");
var startInfo = new ProcessStartInfo
{
FileName = ffmpegPath!,
Arguments = args,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
CreateNoWindow = true,
};
using var process = Process.Start(startInfo);
if (process is not null)
{
process.StandardInput.Close();
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
await process.WaitForExitAsync();
_ = await outputTask;
var stderr = await errorTask;
if (process.ExitCode == 0 && File.Exists(outputFilePath))
{
var fileInfo = new FileInfo(outputFilePath);
Console.WriteLine($"Video created: {outputFilePath} ({fileInfo.Length / 1024 / 1024:F1} MB)");
}
else
{
Console.WriteLine($"FFmpeg encoding failed with exit code {process.ExitCode}");
if (!string.IsNullOrWhiteSpace(stderr))
{
Console.WriteLine($"FFmpeg error: {stderr}");
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($"Error encoding video: {ex.Message}");
}
}
private static string? FindFfmpeg()
{
var pathDirs = Environment.GetEnvironmentVariable("PATH")?.Split(Path.PathSeparator) ?? Array.Empty<string>();
foreach (var dir in pathDirs)
{
var candidate = Path.Combine(dir, "ffmpeg.exe");
if (File.Exists(candidate))
{
return candidate;
}
}
var commonPaths = new[]
{
@"C:\.tools\ffmpeg\bin\ffmpeg.exe",
@"C:\ffmpeg\bin\ffmpeg.exe",
@"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
@"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
$@"{Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)}\Microsoft\WinGet\Links\ffmpeg.exe",
};
foreach (var path in commonPaths)
{
if (File.Exists(path))
{
return path;
}
}
return null;
}
private void Cleanup()
{
recordingCancellation?.Dispose();
recordingCancellation = null;
recordingTask = null;
try
{
if (Directory.Exists(framesDirectory))
{
Directory.Delete(framesDirectory, true);
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to cleanup frames directory: {ex.Message}");
}
}
}

View File

@@ -0,0 +1,421 @@
// 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.
using System.Collections.ObjectModel;
using System.Globalization;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// A test session bound to either a specific window (HWND) or a whole process (name or PID).
/// All <see cref="Find{T}"/>/<see cref="FindAll{T}"/> calls route to <c>winapp ui search</c>
/// scoped by <see cref="TargetFlag"/>/<see cref="TargetValue"/>.
/// </summary>
/// <remarks>
/// Two scopes are supported:
/// <list type="bullet">
/// <item><description><c>Window</c> (<c>-w &lt;hwnd&gt;</c>) — the default. Use when the
/// process owns multiple windows and the test needs to pin one (e.g. ColorPickerUI's
/// overlay vs editor; Settings vs PopupHost).</description></item>
/// <item><description><c>Process</c> (<c>-a &lt;name|pid&gt;</c>) — simpler when the target
/// process owns exactly one user-facing window. Built via <see cref="FromProcess"/>. Matches
/// the pattern in <see href="https://github.com/microsoft/PowerToys/pull/48414"/>.</description></item>
/// </list>
/// </remarks>
public sealed class Session
{
public enum TargetScope
{
/// <summary>Scope all CLI calls to a specific HWND via <c>-w</c>.</summary>
Window,
/// <summary>Scope all CLI calls to a process (name substring or PID) via <c>-a</c>.</summary>
Process,
}
/// <summary>Decimal HWND of the target window, or 0 when bound by <see cref="TargetScope.Process"/>.</summary>
public long WindowHandle { get; }
/// <summary>String form of <see cref="WindowHandle"/> for passing to winappcli's <c>-w</c> flag.</summary>
public string WindowHandleArg { get; }
/// <summary>The scope these calls run against (window or process).</summary>
public TargetScope Scope { get; }
/// <summary>winappcli flag for the active scope (<c>-w</c> or <c>-a</c>).</summary>
public string TargetFlag { get; }
/// <summary>Value to pass after <see cref="TargetFlag"/> — the decimal HWND or the process name/PID.</summary>
public string TargetValue { get; }
public string WindowTitle { get; }
public int ProcessId { get; }
public string ProcessName { get; }
public PowerToysModule InitScope { get; }
/// <summary>True when the target process is elevated; null when unknown (no PID captured).</summary>
public bool? IsElevated => ProcessId > 0 ? ElevationHelper.IsProcessElevated(ProcessId) : null;
internal Session(PowerToysModule scope, long hwnd, string title, int pid, string processName)
{
InitScope = scope;
WindowHandle = hwnd;
WindowHandleArg = hwnd.ToString(CultureInfo.InvariantCulture);
Scope = TargetScope.Window;
TargetFlag = "-w";
TargetValue = WindowHandleArg;
WindowTitle = title;
ProcessId = pid;
ProcessName = processName;
}
private Session(PowerToysModule scope, string appNameOrPid, int pid, string processName, string title)
{
InitScope = scope;
WindowHandle = 0;
WindowHandleArg = "0";
Scope = TargetScope.Process;
TargetFlag = "-a";
TargetValue = appNameOrPid;
WindowTitle = title;
ProcessId = pid;
ProcessName = processName;
}
/// <summary>
/// Build a session scoped to a whole process via <c>winapp ... -a &lt;app&gt;</c>. Cheaper than
/// resolving a HWND and ideal for the single-window-per-process case (e.g. Settings smoke
/// tests). The first matching window's PID/name/title are captured for reporting only — all
/// subsequent CLI calls re-resolve via <c>-a</c>, so window-replacement during the test
/// (re-navigation, page swap) is handled transparently.
/// </summary>
/// <param name="appNameOrPid">Process name substring (e.g. <c>"PowerToys.Settings"</c>) or PID as a string.</param>
/// <param name="attributeAs">Module label used for diagnostics only.</param>
/// <param name="timeoutMS">How long to wait for the process to expose at least one UIA window.</param>
public static Session FromProcess(
string appNameOrPid,
PowerToysModule attributeAs = PowerToysModule.Runner,
int timeoutMS = 10_000)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
var windows = WindowsFinder.ListByApp(appNameOrPid);
if (windows.Count > 0)
{
var w = windows[0];
return new Session(attributeAs, appNameOrPid, w.ProcessId, w.ProcessName, w.Title);
}
Thread.Sleep(250);
}
Assert.Fail(
$"FromProcess('{appNameOrPid}'): no UIA-visible window appeared within {timeoutMS}ms. " +
$"Is the app running? Run 'winapp ui list-windows -a {appNameOrPid}' to confirm.");
return null!;
}
/// <summary>
/// Attach to a running module's first window (window-scoped, so it carries a HWND) and
/// optionally resize it to a preset <see cref="WindowSize"/>. Useful when a test needs a
/// deterministic window size or wants to drive an already-running module.
/// </summary>
public static Session Attach(PowerToysModule module, WindowSize size = WindowSize.UnSpecified, int timeoutMS = 10_000)
{
var processName = ModulePaths.ProcessNameFor(module);
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
var windows = WindowsFinder.ListByApp(processName);
if (windows.Count > 0)
{
var w = windows[0];
if (size != WindowSize.UnSpecified && w.Hwnd != 0)
{
WindowHelper.SetWindowSize(new IntPtr(w.Hwnd), size);
Thread.Sleep(200);
}
return new Session(module, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
}
Thread.Sleep(250);
}
Assert.Fail($"Attach: no UIA-visible window for module {module} ('{processName}') within {timeoutMS}ms.");
return null!;
}
public T Find<T>(By by, int timeoutMS = 5000)
where T : Element, new() => FindUnder<T>(by, timeoutMS);
public T Find<T>(string name, int timeoutMS = 5000)
where T : Element, new() => FindUnder<T>(By.Name(name), timeoutMS);
public Element Find(By by, int timeoutMS = 5000) => FindUnder<Element>(by, timeoutMS);
public Element Find(string name, int timeoutMS = 5000) => FindUnder<Element>(By.Name(name), timeoutMS);
public bool Has<T>(By by, int timeoutMS = 1000)
where T : Element, new() => FindAll<T>(by, timeoutMS).Count >= 1;
public bool Has(By by, int timeoutMS = 1000) => Has<Element>(by, timeoutMS);
public bool Has(string name, int timeoutMS = 1000) => Has<Element>(By.Name(name), timeoutMS);
public bool HasOne<T>(By by, int timeoutMS = 1000)
where T : Element, new() => FindAll<T>(by, timeoutMS).Count == 1;
/// <summary>
/// All elements matching <paramref name="by"/> on this session's window, optionally polling
/// for up to <paramref name="timeoutMS"/> if none are present initially.
/// </summary>
public ReadOnlyCollection<T> FindAll<T>(By by, int timeoutMS = 5000)
where T : Element, new()
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (true)
{
var matches = ExecuteSearch(by);
var typed = new List<T>(matches.Count);
foreach (var m in matches)
{
var e = new T
{
Owner = this,
Selector = m.Selector,
ControlType = m.ControlType,
ClassName = m.ClassName,
Name = m.Name,
X = m.X,
Y = m.Y,
Width = m.Width,
Height = m.Height,
};
if (e.MatchesFilter())
{
typed.Add(e);
}
}
if (typed.Count > 0 || DateTime.UtcNow >= deadline)
{
return new ReadOnlyCollection<T>(typed);
}
Thread.Sleep(100);
}
}
internal T FindUnder<T>(By by, int timeoutMS)
where T : Element, new()
{
var collection = FindAll<T>(by, timeoutMS);
Assert.IsTrue(collection.Count > 0, $"UI-Element({typeof(T).Name}) not found using selector: {by}");
return collection[0];
}
/// <summary>
/// Generic polling helper, equivalent to winappcli's <c>wait-for --value</c> but evaluated in C#
/// so the predicate can read multiple properties / compose conditions.
/// </summary>
public bool WaitFor(Func<bool> condition, int timeoutMS = 5000, int pollIntervalMS = 100)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
try
{
if (condition())
{
return true;
}
}
catch
{
// Treat property reads on stale elements as "not yet true".
}
Thread.Sleep(pollIntervalMS);
}
return false;
}
/// <summary>
/// Wait for an element matching <paramref name="by"/> to appear in the tree via
/// <c>winapp ui wait-for</c>. Returns true if it appeared within <paramref name="timeoutMS"/>.
/// </summary>
public bool WaitForElement(By by, int timeoutMS = 5000)
{
var r = WinappCli.Invoke(
"ui", "wait-for", by.Value,
TargetFlag, TargetValue,
"-t", timeoutMS.ToString(CultureInfo.InvariantCulture));
return r.ExitCode == 0;
}
/// <summary>
/// Capture a PNG of the session's target via <c>winapp ui screenshot</c>. Pass an
/// <paramref name="element"/> to crop to that element's bounds, or set
/// <paramref name="captureScreen"/> to grab from the screen (includes popups / overlays /
/// flyouts that <c>PrintWindow</c> misses).
/// </summary>
public string Screenshot(string outputPath, Element? element = null, bool captureScreen = false)
{
WinappCli.InvokeAssertSuccess(BuildScreenshotArgs(outputPath, element, captureScreen));
return outputPath;
}
/// <summary>Non-asserting screenshot for cleanup / failure-artifact paths. Returns false on error.</summary>
public bool TryScreenshot(string outputPath, Element? element = null, bool captureScreen = false)
{
try
{
return WinappCli.Invoke(BuildScreenshotArgs(outputPath, element, captureScreen)).Success;
}
catch
{
return false;
}
}
private string[] BuildScreenshotArgs(string outputPath, Element? element, bool captureScreen)
{
var args = new List<string> { "ui", "screenshot" };
if (element is not null && !string.IsNullOrEmpty(element.Selector))
{
args.Add(element.Selector);
}
args.Add(TargetFlag);
args.Add(TargetValue);
args.Add("-o");
args.Add(outputPath);
if (captureScreen)
{
args.Add("--capture-screen");
}
return args.ToArray();
}
/// <summary>
/// Dump the UIA tree for this session's target via <c>winapp ui inspect --json</c>.
/// Returned shape: <c>{ "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }</c>.
/// </summary>
/// <param name="depth">Tree depth (ignored by winappcli when <paramref name="interactive"/> is set).</param>
/// <param name="interactive">Only invokable elements (auto-depth), as a flat list.</param>
/// <param name="hideDisabled">Omit disabled elements.</param>
/// <param name="hideOffscreen">Omit off-screen elements.</param>
public JsonElement Inspect(int depth = 6, bool interactive = false, bool hideDisabled = false, bool hideOffscreen = false)
{
var args = new List<string>
{
"ui", "inspect",
TargetFlag, TargetValue,
"--json",
"-d", depth.ToString(CultureInfo.InvariantCulture),
};
if (interactive)
{
args.Add("--interactive");
}
if (hideDisabled)
{
args.Add("--hide-disabled");
}
if (hideOffscreen)
{
args.Add("--hide-offscreen");
}
return WinappCli.InvokeJson(args.ToArray());
}
/// <summary>
/// Walk the ancestor chain from <paramref name="element"/> up to the root via
/// <c>winapp ui inspect --ancestors</c>.
/// </summary>
public JsonElement InspectAncestors(Element element) =>
WinappCli.InvokeJson("ui", "inspect", "--ancestors", element.Selector, TargetFlag, TargetValue, "--json");
/// <summary>The element that currently has keyboard focus, via <c>winapp ui get-focused --json</c>.</summary>
public JsonElement GetFocused() => WinappCli.InvokeJson("ui", "get-focused", TargetFlag, TargetValue, "--json");
/// <summary>
/// Convenience reader for the focused element's Name (empty if none / unknown). Useful for
/// keyboard-navigation assertions.
/// </summary>
public string GetFocusedName()
{
try
{
var root = GetFocused();
foreach (var prop in new[] { "name", "Name" })
{
if (root.TryGetProperty(prop, out var v) && v.ValueKind == JsonValueKind.String)
{
return v.GetString() ?? string.Empty;
}
}
}
catch
{
// Best effort — no focused element or unexpected envelope.
}
return string.Empty;
}
/// <summary>Connection / target info for diagnostics via <c>winapp ui status --json</c>.</summary>
public JsonElement Status() => WinappCli.InvokeJson("ui", "status", TargetFlag, TargetValue, "--json");
/// <summary>Send keystrokes via Win32 <c>keybd_event</c>. Required for global PowerToys hotkeys.</summary>
public void SendKeys(params Key[] keys) => KeyboardHelper.SendKeys(keys);
public void Cleanup()
{
// Stateless — nothing to release on the wire.
}
private List<SearchHit> ExecuteSearch(By by)
{
// winappcli accepts the selector text directly as the first positional argument.
var root = WinappCli.InvokeJson("ui", "search", by.Value, TargetFlag, TargetValue, "--json");
var result = new List<SearchHit>();
if (root.TryGetProperty("matches", out var arr) && arr.ValueKind == JsonValueKind.Array)
{
foreach (var m in arr.EnumerateArray())
{
result.Add(new SearchHit(
Selector: m.TryGetProperty("selector", out var s) ? (s.GetString() ?? string.Empty) : string.Empty,
Name: m.TryGetProperty("name", out var n) ? (n.GetString() ?? string.Empty) : string.Empty,
ControlType: m.TryGetProperty("type", out var t) ? (t.GetString() ?? string.Empty) : string.Empty,
ClassName: m.TryGetProperty("className", out var c) ? (c.GetString() ?? string.Empty) : string.Empty,
X: ReadInt(m, "x"),
Y: ReadInt(m, "y"),
Width: ReadInt(m, "width"),
Height: ReadInt(m, "height")));
}
}
return result;
static int ReadInt(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number ? v.GetInt32() : 0;
}
private sealed record SearchHit(string Selector, string Name, string ControlType, string ClassName, int X, int Y, int Width, int Height);
}

View File

@@ -0,0 +1,380 @@
// 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.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Owns process launch + window resolution for a <see cref="PowerToysModule"/>. Equivalent to
/// the old <c>SessionHelper</c> but the engine is winappcli — no WinAppDriver, no Appium.
/// </summary>
/// <remarks>
/// <para>
/// Two consumption shapes:
/// <list type="bullet">
/// <item><description>Per-test (HWND-scoped): construct + call <see cref="Init"/>. <see cref="UITestBase"/>
/// does this in <c>[TestInitialize]</c>.</description></item>
/// <item><description>Class-scoped or process-scoped: the static helpers (<see cref="EnsureRunning"/>,
/// <see cref="IsRunning"/>, <see cref="GetProcessName"/>) let a smoke-test <c>[ClassInitialize]</c>
/// reuse the launch+wait flow without taking on a HWND binding.</description></item>
/// </list>
/// </para>
/// </remarks>
public sealed class SessionHelper
{
// Generous window-appearance budget. On a cold/busy CI agent the runner spends tens of seconds
// enabling every module and the Settings WinUI process cold-starts before its window appears.
// When the whole test job runs elevated (required so the legacy WinAppDriver harness can bind
// :4723) the runner's startup is slower still — ~100s to the first Settings window observed on a
// slow platform — so the budget is 150s. We wait patiently (and only re-issue the launch when
// nothing is alive) rather than kill-and-relaunch on a short deadline, which only resets a
// slow-but-healthy startup and never converges.
private static readonly TimeSpan LaunchTimeout = TimeSpan.FromSeconds(150);
private readonly PowerToysModule scope;
// True when this helper's Init/Restart actually launched the scope (vs. attaching to an
// already-running instance). StopIfStarted only tears down what we created.
private bool launchedByUs;
public SessionHelper(PowerToysModule scope)
{
this.scope = scope;
}
public Session Init()
{
launchedByUs = EnsureRunning(scope, LaunchTimeout);
return ResolveMainWindowOrFail();
}
/// <summary>
/// Force a clean restart of this helper's scope: kill the scope process (plus the runner for the
/// Settings scope), relaunch, and rebind to the fresh window. Marks the session launched-by-us so
/// <see cref="StopIfStarted"/> tears it down. Mirrors the net effect of the legacy <c>RestartScopeExe</c>.
/// </summary>
public Session Restart()
{
StopScope();
EnsureRunning(scope, LaunchTimeout);
launchedByUs = true;
return ResolveMainWindowOrFail();
}
/// <summary>
/// Stop the process(es) this helper launched. No-op when the target was already running at
/// <see cref="Init"/> time — we never kill state the test didn't create. Mirrors the legacy
/// <c>ExitScopeExe</c>, scoped to "only what we started".
/// </summary>
public void StopIfStarted()
{
if (!launchedByUs)
{
return;
}
StopScope();
launchedByUs = false;
}
private Session ResolveMainWindowOrFail()
{
var window = WaitForMainWindow(scope, LaunchTimeout);
Assert.IsNotNull(window, $"Main window for {scope} did not appear via winappcli within {LaunchTimeout.TotalSeconds:0}s");
return window!;
}
/// <summary>
/// Kill the scope's process and, for the Settings scope, the runner that owns it (the runner's
/// exit also stops the modules it spawned). Uses exact-name matching so unrelated processes that
/// merely contain "PowerToys" in their name (e.g. the test host) are left alone. Waits briefly
/// for the scope process to disappear.
/// </summary>
private void StopScope() => KillScopeProcessesAndWait(scope);
/// <summary>Process name as winappcli's <c>-a</c> flag (and <see cref="Process.GetProcessesByName(string)"/>) accept it.</summary>
public static string GetProcessName(PowerToysModule scope) => ModulePaths.ProcessNameFor(scope);
/// <summary>Returns <c>true</c> if at least one process matching <paramref name="scope"/> is running.</summary>
public static bool IsRunning(PowerToysModule scope) =>
Process.GetProcessesByName(GetProcessName(scope)).Length > 0;
/// <summary>
/// Ensure the runner-owned environment for <paramref name="scope"/> is up and has presented a
/// UIA-visible window. Returns <c>false</c> when the target was already running (nothing
/// launched), <c>true</c> when a launch was needed — callers track this so cleanup only kills
/// what the test itself started.
/// </summary>
/// <remarks>
/// <para>
/// The PowerToys <b>runner</b> (<c>PowerToys.exe</c>) is the single entry point. It installs the
/// centralized keyboard hook and owns every module's start/stop lifecycle. Tests therefore
/// launch the runner and drive modules through the Settings UI — they never launch a module's
/// UI exe (e.g. <c>PowerToys.ColorPickerUI.exe</c>) standalone. A standalone module process has
/// no runner behind it, so its activation hotkey never fires and toggling it in Settings does
/// nothing. For the <see cref="PowerToysModule.PowerToysSettings"/> scope we launch
/// <c>PowerToys.exe --open-settings</c>: the runner starts (or, being single-instance, the
/// already-running one is signalled) and presents the Settings window.
/// </para>
/// <para>
/// <c>UseShellExecute = true</c> is intentional: with <c>UseShellExecute = false</c> the
/// spawned process inherits this test-host's stdin/stdout/stderr handles, and the
/// Microsoft.Testing.Platform / MSTest runner won't declare the test run complete until
/// those pipes drain — which never happens until the target exits. Going through
/// ShellExecute gives the child its own console and detaches the handles.
/// </para>
/// <para>
/// PowerToys processes with single-instance gates (runner, Settings, ColorPicker) often hand
/// off to an existing instance and let the launcher PID exit with code 0 immediately. The
/// launcher PID is therefore intentionally discarded; readiness is judged purely by whether a
/// UIA window owned by the target process becomes visible.
/// </para>
/// </remarks>
public static bool EnsureRunning(PowerToysModule scope, TimeSpan timeout)
{
// Whether or not the scope process already exists, the test needs its WINDOW. EnsureWindow
// waits patiently and (idempotently) re-issues the launch as needed; it only kills/relaunches
// a genuinely-dead fresh launch, never a slow-but-healthy or class-shared (reused) window.
var alreadyRunning = IsRunning(scope);
EnsureWindow(scope, timeout, alreadyRunning);
return !alreadyRunning;
}
/// <summary>
/// Wait for a UIA-visible window from <paramref name="scope"/> to appear, launching / re-issuing
/// the launch as needed. The Settings scope is launched through the runner
/// (<c>PowerToys.exe --open-settings</c>); see <see cref="EnsureRunning"/> remarks.
/// </summary>
/// <remarks>
/// On a busy/cold CI agent the runner spends tens of seconds enabling every module before the
/// Settings window appears (~30-50s observed). A "kill + relaunch every 20s" loop kept resetting
/// that slow-but-healthy startup so it never converged (the "runner: 1, Settings: 2, no window"
/// failures). Instead this waits a single generous <paramref name="timeout"/> and only acts when
/// the window is still missing after a grace period: it re-issues the launch — idempotent, since
/// the runner is single-instance, so <c>--open-settings</c> just (re)shows Settings — and
/// additionally clears the single-instance mutex first only for a fresh launch that has gone
/// completely dead (nothing running), i.e. the handoff-to-a-now-exited-instance race. A
/// class-shared (reused) window is never killed.
/// </remarks>
private static void EnsureWindow(PowerToysModule scope, TimeSpan timeout, bool alreadyRunning)
{
var processName = GetProcessName(scope);
var runnerName = GetProcessName(PowerToysModule.Runner);
var nudgeInterval = TimeSpan.FromSeconds(25);
if (!alreadyRunning)
{
// Release the single-instance mutex any stale/half-launched instance still holds (pre-test
// hygiene kills without waiting), then launch.
KillScopeProcessesAndWait(scope);
LaunchScope(scope);
}
var deadline = DateTime.UtcNow + timeout;
var lastLaunch = DateTime.UtcNow;
while (DateTime.UtcNow < deadline)
{
if (WindowsFinder.ListByApp(processName).Count > 0)
{
// Give XAML a moment to populate the visual tree.
Thread.Sleep(750);
return;
}
if (DateTime.UtcNow - lastLaunch > nudgeInterval)
{
// Re-issue the launch ONLY when nothing is alive to present the window — the genuine
// "launcher handed off to an instance that then exited" race. If the runner is still
// alive it already owns the queued --open-settings request and, on a slow agent, may
// need tens of seconds to enable every module before it spawns Settings. Re-launching
// there is NOT free: each extra --open-settings queues another request that the runner
// honours with a SEPARATE Settings.exe (the "Settings: 3" pile-up seen in CI), and the
// competing single-instance processes plus the launch contention push the window past
// the deadline. So when anything is alive, keep waiting instead of piling on.
var alive = IsRunning(scope) || Process.GetProcessesByName(runnerName).Length > 0;
if (!alive)
{
if (!alreadyRunning)
{
KillScopeProcessesAndWait(scope);
}
LaunchScope(scope);
lastLaunch = DateTime.UtcNow;
}
}
Thread.Sleep(500);
}
Assert.Fail(
$"No UIA-visible window from process '{processName}' appeared within {timeout.TotalSeconds:0}s. " +
$"Live processes — runner '{runnerName}': {Process.GetProcessesByName(runnerName).Length}, " +
$"'{processName}': {Process.GetProcessesByName(processName).Length}.");
}
/// <summary>
/// Issue a single detached launch for <paramref name="scope"/>: the runner with
/// <c>--open-settings</c> for the Settings scope (the runner owns the Settings UI — see
/// <see cref="EnsureRunning"/> remarks), or the scope's own exe otherwise.
/// </summary>
private static void LaunchScope(PowerToysModule scope)
{
if (scope == PowerToysModule.PowerToysSettings)
{
LaunchViaShell(ModulePaths.ExePathFor(PowerToysModule.Runner), "--open-settings");
}
else
{
LaunchViaShell(ModulePaths.ExePathFor(scope), null);
}
}
/// <summary>
/// Kill the scope's process — plus the runner for the Settings scope, which owns the
/// single-instance mutex that <c>--open-settings</c> hands off to — and wait for them to exit.
/// The wait is the point: relaunching while a just-killed runner still holds its mutex hands the
/// new launch off to the dying instance, which never presents a window.
/// </summary>
private static void KillScopeProcessesAndWait(PowerToysModule scope)
{
var names = scope == PowerToysModule.PowerToysSettings
? new[] { GetProcessName(PowerToysModule.PowerToysSettings), GetProcessName(PowerToysModule.Runner) }
: new[] { GetProcessName(scope) };
foreach (var name in names)
{
WindowControl.TryKillProcessByName(name);
}
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
while (DateTime.UtcNow < deadline && names.Any(n => Process.GetProcessesByName(n).Length > 0))
{
Thread.Sleep(150);
}
}
/// <summary>
/// Launch <paramref name="exe"/> detached via ShellExecute (see <see cref="EnsureRunning"/>
/// remarks for why <c>UseShellExecute = true</c> is required). The launcher PID is discarded;
/// readiness is judged by window presence, not the process handle.
/// </summary>
private static void LaunchViaShell(string exe, string? arguments)
{
Assert.IsTrue(File.Exists(exe), $"Executable not found: {exe}");
try
{
using (Process.Start(new ProcessStartInfo
{
FileName = exe,
Arguments = arguments ?? string.Empty,
WorkingDirectory = Path.GetDirectoryName(exe)!,
UseShellExecute = true,
}) ?? throw new InvalidOperationException($"Process.Start returned null for {exe}"))
{
// Fire and forget — see EnsureRunning <remarks>.
}
}
catch (Exception ex)
{
Assert.Fail($"Failed to launch '{exe} {arguments}': {ex.Message}");
}
}
/// <summary>
/// Force a clean restart of the module: kill any running instance, wait for it to exit, then
/// launch a fresh one and wait for its window. Returns true once a window is visible.
/// </summary>
public static bool RestartScope(PowerToysModule scope, TimeSpan timeout)
{
// Exact-name kill (KillScopeProcessesAndWait) rather than the substring-based
// WindowControl.TryKillProcess, so a short scope name like "PowerToys" can't take down
// unrelated processes whose names merely contain it (e.g. a "PowerToys.*.UITests" test host).
// It also already waits for the processes to exit.
KillScopeProcessesAndWait(scope);
return EnsureRunning(scope, timeout);
}
/// <summary>
/// Poll <c>winapp ui list-windows --json</c> until a window matching the target module appears.
/// Returns a <see cref="Session"/> bound to its HWND.
/// </summary>
/// <remarks>
/// When the same process owns multiple windows (Settings exe also owns the <c>PopupHost</c>
/// overlay), we strictly prefer a window whose title contains the expected title. Process-name
/// match is only used as a fallback for modules that don't pin a specific title.
/// </remarks>
private static Session? WaitForMainWindow(PowerToysModule scope, TimeSpan timeout)
{
var processName = ModulePaths.ProcessNameFor(scope);
var expectedTitle = ModulePaths.MainWindowTitleFor(scope);
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
var r = WinappCli.Invoke("ui", "list-windows", "--json");
if (r.Success && !string.IsNullOrEmpty(r.StdOut))
{
try
{
using var doc = JsonDocument.Parse(r.StdOut);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
Session? processFallback = null;
foreach (var w in doc.RootElement.EnumerateArray())
{
var pn = w.TryGetProperty("processName", out var pnEl) ? (pnEl.GetString() ?? string.Empty) : string.Empty;
var title = w.TryGetProperty("title", out var tEl) ? (tEl.GetString() ?? string.Empty) : string.Empty;
var hwnd = w.TryGetProperty("hwnd", out var hwndEl) && hwndEl.ValueKind == JsonValueKind.Number ? hwndEl.GetInt64() : 0L;
var pid = w.TryGetProperty("processId", out var pidEl) && pidEl.ValueKind == JsonValueKind.Number ? pidEl.GetInt32() : 0;
if (hwnd == 0)
{
continue;
}
// Strict title match wins immediately — disambiguates from sibling
// windows owned by the same process (e.g. Settings + PopupHost).
if (!string.IsNullOrEmpty(expectedTitle) &&
title.Contains(expectedTitle, StringComparison.OrdinalIgnoreCase))
{
return new Session(scope, hwnd, title, pid, pn);
}
// Track the first process-name match as a fallback for modules where no
// expected title is configured.
if (processFallback is null &&
!string.IsNullOrEmpty(processName) &&
pn.Contains(processName, StringComparison.OrdinalIgnoreCase))
{
processFallback = new Session(scope, hwnd, title, pid, pn);
}
}
// No title match yet — only fall back to the process match if the module
// really has no expected title configured.
if (string.IsNullOrEmpty(expectedTitle) && processFallback is not null)
{
return processFallback;
}
}
}
catch
{
// Bad JSON during startup — keep polling.
}
}
Thread.Sleep(250);
}
return null;
}
}

View File

@@ -0,0 +1,105 @@
// 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.
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Lightweight helpers for preparing PowerToys settings JSON before a test launches a module.
/// Reads/writes the JSON files directly with System.Text.Json so the harness keeps zero product
/// dependencies — unlike the legacy helper, which referenced <c>Settings.UI.Library</c>.
/// </summary>
public static class SettingsConfigHelper
{
private static readonly JsonSerializerOptions Indented = new() { WriteIndented = true };
/// <summary>Root of the per-user PowerToys settings: <c>%LocalAppData%\Microsoft\PowerToys</c>.</summary>
public static string PowerToysSettingsRoot => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys");
private static string GlobalSettingsPath => Path.Combine(PowerToysSettingsRoot, "settings.json");
/// <summary>
/// Enable exactly the named modules in the global <c>settings.json</c> and disable every other
/// module already listed. Module names are the keys under <c>"enabled"</c> (e.g. "FancyZones",
/// "ColorPicker", "Peek"). Creates the file and keys when missing.
/// </summary>
public static void ConfigureGlobalModuleSettings(params string[]? modulesToEnable)
{
modulesToEnable ??= Array.Empty<string>();
Directory.CreateDirectory(PowerToysSettingsRoot);
var root = File.Exists(GlobalSettingsPath)
? (JsonNode.Parse(File.ReadAllText(GlobalSettingsPath)) as JsonObject) ?? new JsonObject()
: new JsonObject();
if (root["enabled"] is not JsonObject enabled)
{
enabled = new JsonObject();
root["enabled"] = enabled;
}
// Flip every already-listed module based on membership (disables the rest).
foreach (var key in enabled.Select(kv => kv.Key).ToList())
{
enabled[key] = modulesToEnable.Any(m => string.Equals(m, key, StringComparison.Ordinal));
}
// Ensure the requested modules are present and enabled even if not previously listed.
foreach (var module in modulesToEnable)
{
enabled[module] = true;
}
File.WriteAllText(GlobalSettingsPath, root.ToJsonString(Indented));
}
/// <summary>
/// Update a module's <c>settings.json</c>
/// (<c>%LocalAppData%\Microsoft\PowerToys\&lt;module&gt;\settings.json</c>). Seeds the file from
/// <paramref name="defaultSettingsContent"/> when it doesn't exist, then applies
/// <paramref name="updateSettingsAction"/> to the parsed object and writes it back.
/// </summary>
public static void UpdateModuleSettings(
string moduleName,
string defaultSettingsContent,
Action<JsonObject> updateSettingsAction)
{
ArgumentNullException.ThrowIfNull(moduleName);
ArgumentNullException.ThrowIfNull(updateSettingsAction);
var moduleDir = Path.Combine(PowerToysSettingsRoot, moduleName);
var settingsPath = Path.Combine(moduleDir, "settings.json");
Directory.CreateDirectory(moduleDir);
var existing = File.Exists(settingsPath) ? File.ReadAllText(settingsPath) : string.Empty;
JsonObject settings;
if (string.IsNullOrWhiteSpace(existing))
{
if (string.IsNullOrWhiteSpace(defaultSettingsContent))
{
throw new ArgumentException(
"Default settings content must be provided when the file doesn't exist.",
nameof(defaultSettingsContent));
}
settings = (JsonNode.Parse(defaultSettingsContent) as JsonObject)
?? throw new InvalidOperationException($"Default settings for '{moduleName}' is not a JSON object.");
}
else
{
settings = (JsonNode.Parse(existing) as JsonObject)
?? throw new InvalidOperationException($"Existing settings for '{moduleName}' is not a JSON object.");
}
updateSettingsAction(settings);
File.WriteAllText(settingsPath, settings.ToJsonString(Indented));
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!--
WinForms is needed for System.Windows.Forms.SendKeys.SendWait, used by the global-hotkey
injection in KeyboardHelper. (Same approach as the legacy harness.)
-->
<UseWindowsForms>true</UseWindowsForms>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.PowerToys.UITest.Next</RootNamespace>
<AssemblyName>Microsoft.PowerToys.UITest.Next</AssemblyName>
</PropertyGroup>
<ItemGroup>
<!--
Engine is winappcli (Microsoft.WinAppCli) — installed once per machine via
`winget install Microsoft.winappcli`. We shell out to winapp.exe and parse its
JSON output. No managed dependency on the engine — only MSTest's attribute surface.
-->
<PackageReference Include="MSTest.TestFramework" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,544 @@
// 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.
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Base class for the next-generation PowerToys UI tests. Engine is winappcli — every UI call
/// shells out to <c>winapp.exe</c>. No WinAppDriver, no Selenium, no third-party NuGet packages.
/// </summary>
/// <remarks>
/// <para>
/// Drop-in shape replacement for the existing <c>Microsoft.PowerToys.UITest.UITestBase</c>:
/// inherit, pass a <see cref="PowerToysModule"/>, and use <c>Session</c> / <c>Find&lt;T&gt;</c> in tests.
/// </para>
/// <para>
/// Test Explorer integration is automatic — MSTest's <c>[TestClass]</c> / <c>[TestInitialize]</c> /
/// <c>[TestCleanup]</c> plus the Microsoft.Testing.Platform runner (enabled repo-wide in
/// <c>Directory.Build.props</c>) are everything Test Explorer and <c>dotnet test</c> need.
/// </para>
/// </remarks>
[TestClass]
public class UITestBase : IDisposable
{
/// <summary>
/// Lazy one-shot probe for <c>winapp.exe</c>. Runs the first time any UITest in the
/// process initializes — the cost is one extra <c>winapp --version</c> call per test run.
/// </summary>
private static readonly Lazy<bool> CliAvailable = new(WinappCli.IsAvailable);
// Class-scoped reuse (opt-in via ReuseScopeAcrossTests): the launcher that owns the shared scope
// and the test class it belongs to. UI tests never run in parallel, so one slot is enough; the
// inherited ClassCleanup stops it once the owning class finishes.
private static SessionHelper? keepAliveHelper;
private static Type? keepAliveOwner;
private readonly PowerToysModule scope;
private readonly WindowSize windowSize;
private readonly string[]? enableModules;
private readonly bool isInPipeline = EnvironmentConfig.IsInPipeline;
private SessionHelper? sessionHelper;
private System.Threading.Timer? screenshotTimer;
private ScreenRecording? screenRecording;
private string? screenshotDirectory;
private string? recordingDirectory;
private bool artifactsCaptured;
private bool disposed;
public required TestContext TestContext { get; set; }
public Session Session { get; private set; } = null!;
/// <summary>
/// PowerToys processes killed before every test so each run starts from a clean desktop state
/// (mirrors the legacy harness's <c>CloseOtherApplications</c>). Override to extend the list with
/// a module's helper processes. Matched by exact name, so short names like "PowerToys" don't hit
/// unrelated processes.
/// </summary>
protected virtual IReadOnlyList<string> StaleProcessNames { get; } = new[]
{
"PowerToys",
"PowerToys.Settings",
"PowerToys.FancyZonesEditor",
};
/// <summary>
/// When a derived class overrides this to <c>true</c>, the module is launched once for the whole
/// class and the <b>same window is reused across every test method</b> (no per-test relaunch or
/// desktop hygiene). The framework still captures failure media per test and stops the scope once
/// the class finishes. Default <c>false</c> — each test gets an isolated launch + teardown.
/// </summary>
protected virtual bool ReuseScopeAcrossTests => false;
/// <param name="scope">Module whose window the test drives.</param>
/// <param name="size">Optional fixed window size applied once the window appears.</param>
/// <param name="enableModules">
/// When non-null, exactly these modules are enabled (and every other listed module disabled) in
/// the global <c>settings.json</c> before the runner launches — a deterministic module baseline.
/// Leave null to launch against whatever state <c>settings.json</c> already holds.
/// </param>
protected UITestBase(
PowerToysModule scope = PowerToysModule.PowerToysSettings,
WindowSize size = WindowSize.UnSpecified,
string[]? enableModules = null)
{
this.scope = scope;
this.windowSize = size;
this.enableModules = enableModules;
}
[TestInitialize]
public async Task TestInit()
{
if (!CliAvailable.Value)
{
Assert.Fail(WinappCli.InstallHint);
}
try
{
// Reuse the already-open window from a previous test in this class when the class opted
// into a shared scope and it's still alive — skip the hygiene that would minimize/kill it.
var reuse = ReuseScopeAcrossTests
&& keepAliveOwner == GetType()
&& SessionHelper.IsRunning(scope);
if (!reuse)
{
// Pin the display to a known resolution so coordinate-sensitive tests are
// deterministic, and snapshot the monitor topology for post-mortem diagnostics.
if (isInPipeline)
{
DisplayHelper.NormalizeResolution(1920, 1080);
DisplayHelper.LogMonitors(TestContext);
}
PreTestHygiene();
// Seed a deterministic module on/off baseline before the runner reads settings.json.
if (enableModules is not null)
{
SettingsConfigHelper.ConfigureGlobalModuleSettings(enableModules);
}
}
// Start the 1s screenshot timer + FFmpeg recording before the UI work so the artifacts
// cover the whole test.
if (isInPipeline)
{
StartPipelineCapture();
}
sessionHelper = new SessionHelper(scope);
Session = sessionHelper.Init(); // launches when needed; reuses a running instance otherwise
ApplyWindowSize();
// Remember the launcher so the inherited ClassCleanup can stop the shared scope at the
// end of the class.
if (ReuseScopeAcrossTests && !reuse)
{
keepAliveHelper = sessionHelper;
keepAliveOwner = GetType();
}
}
catch
{
// MSTest does NOT run [TestCleanup] when [TestInitialize] throws, so capture the failure
// media here (e.g. the window never appeared) before propagating — otherwise an init
// failure would attach no diagnostics at all.
await CaptureFailureArtifactsAsync();
throw;
}
}
[TestCleanup]
public async Task TestCleanup()
{
var failed = TestContext.CurrentTestOutcome is
UnitTestOutcome.Failed or UnitTestOutcome.Error or UnitTestOutcome.Unknown;
if (failed)
{
await CaptureFailureArtifactsAsync();
}
else if (isInPipeline)
{
// Passing test: stop the capture and discard the (now uninteresting) recording.
await StopPipelineCaptureAsync();
CleanupRecordingDirectory();
}
// Tear the scope down only when each test owns its launch. With a class-shared scope the
// window must survive for the next test; the inherited ClassCleanup stops it at class end.
if (!ReuseScopeAcrossTests)
{
try
{
sessionHelper?.StopIfStarted();
}
catch
{
}
}
Dispose();
}
/// <summary>
/// Stop a class-shared scope (see <see cref="ReuseScopeAcrossTests"/>) once the owning class's
/// tests finish. Runs after every derived class via inheritance; a no-op for classes that never
/// kept a scope alive.
/// </summary>
[ClassCleanup(InheritanceBehavior.BeforeEachDerivedClass)]
public static void StopSharedScope()
{
try
{
keepAliveHelper?.StopIfStarted();
}
catch
{
}
keepAliveHelper = null;
keepAliveOwner = null;
}
/// <summary>
/// Collect every diagnostic for a failed test and attach it: a window-independent desktop
/// screenshot always, plus (in pipeline mode) the 1s screenshot trail, the screen recording, and
/// the PowerToys log files. Idempotent and fully tolerant — runs from both the <see cref="TestInit"/>
/// failure path (where <c>[TestCleanup]</c> won't fire) and <see cref="TestCleanup"/>.
/// </summary>
private async Task CaptureFailureArtifactsAsync()
{
if (artifactsCaptured)
{
return;
}
artifactsCaptured = true;
if (isInPipeline)
{
try
{
await StopPipelineCaptureAsync();
}
catch
{
}
}
try
{
CaptureFailureScreenshot();
}
catch
{
}
if (isInPipeline)
{
try
{
AddScreenshotsToTestResults();
AddRecordingsToTestResults();
AddLogFilesToTestResults();
}
catch
{
}
}
}
/// <summary>
/// Attach a failure screenshot. The primary capture is a window-independent GDI grab of the
/// desktop, so it works even when the test's window was already closed (e.g. by the test's own
/// <c>finally</c>) or never appeared (an init failure) — unlike winappcli's <c>--capture-screen</c>,
/// which requires a live target window. When the session window is still live, a winapp
/// window/overlay shot is added too. Best-effort.
/// </summary>
private void CaptureFailureScreenshot()
{
var dir = TestContext.TestRunResultsDirectory ?? TestContext.TestResultsDirectory ?? Path.GetTempPath();
Directory.CreateDirectory(dir);
var baseName = $"{TestContext.TestName}_{DateTime.Now:yyyyMMdd_HHmmss}";
// Reliable, window-independent desktop grab.
var desktopShot = Path.Combine(dir, $"{baseName}.png");
if (ScreenCapture.TryCaptureDesktop(desktopShot) && File.Exists(desktopShot))
{
TestContext.AddResultFile(desktopShot);
}
// Bonus detail: the winapp window/overlay shot, only when the session window is still alive.
if (Session is not null && Session.WindowHandle != 0)
{
var windowShot = Path.Combine(dir, $"{baseName}_window.png");
try
{
if (Session.TryScreenshot(windowShot, captureScreen: true) && File.Exists(windowShot))
{
TestContext.AddResultFile(windowShot);
}
}
catch
{
}
}
}
/// <summary>
/// Bring the desktop to a known state before launching: minimize every window, dismiss any
/// lingering popup with <c>Esc</c>, and kill the stale PowerToys processes in
/// <see cref="StaleProcessNames"/>. Best-effort — never blocks a test from starting.
/// </summary>
private void PreTestHygiene()
{
try
{
// Minimize all windows so the test starts from a known desktop state.
KeyboardHelper.SendKeys(Key.LWin, Key.M);
// Dismiss any lingering popup / flyout.
KeyboardHelper.SendKeys(Key.Esc);
// Kill stale PowerToys processes so each test launches fresh.
foreach (var processName in StaleProcessNames)
{
WindowControl.TryKillProcessByName(processName);
}
}
catch
{
// Hygiene is opportunistic; a failure here must not fail the test.
}
}
/// <summary>Apply the constructor's <see cref="WindowSize"/> to the resolved window, if any.</summary>
private void ApplyWindowSize()
{
if (Session is null || Session.WindowHandle == 0)
{
return;
}
var hwnd = new IntPtr(Session.WindowHandle);
if (windowSize == WindowSize.UnSpecified)
{
// No explicit size requested: maximize so the whole window is on-screen and every control is
// reachable. PowerToys restores a module's last window rect, which on a CI agent is often small
// or pushed off the side of the screen; for Settings that collapses the NavigationView pane and
// breaks nav-item lookups (e.g. SystemToolsNavItem). Maximizing is the deterministic default.
WindowHelper.MaximizeWindow(hwnd);
}
else
{
WindowHelper.SetWindowSize(hwnd, windowSize);
}
Thread.Sleep(200);
}
/// <summary>
/// Force a clean restart of the scope (kill + relaunch + rebind to the fresh window), re-seeding
/// the module baseline first. Equivalent to the legacy <c>RestartScopeExe</c>; assigns and returns
/// the new <see cref="Session"/>.
/// </summary>
/// <param name="enableModules">
/// Modules to enable before relaunch. When null, the baseline passed to the constructor (if any)
/// is re-applied so the restart stays deterministic.
/// </param>
public Session RestartScope(string[]? enableModules = null)
{
var modules = enableModules ?? this.enableModules;
if (modules is not null)
{
SettingsConfigHelper.ConfigureGlobalModuleSettings(modules);
}
Session = sessionHelper!.Restart();
ApplyWindowSize();
return Session;
}
// ----- Pipeline diagnostics (CI only) ---------------------------------------------------
/// <summary>Start the 1s screenshot timer and FFmpeg screen recording. Best-effort.</summary>
private void StartPipelineCapture()
{
try
{
var baseDirectory = TestContext.TestResultsDirectory ?? Path.GetTempPath();
screenshotDirectory = Path.Combine(baseDirectory, "UITestScreenshots_" + Guid.NewGuid());
Directory.CreateDirectory(screenshotDirectory);
screenshotTimer = new System.Threading.Timer(
ScreenCapture.TimerCallback, screenshotDirectory, TimeSpan.Zero, TimeSpan.FromMilliseconds(1000));
recordingDirectory = Path.Combine(baseDirectory, "UITestRecordings_" + Guid.NewGuid());
Directory.CreateDirectory(recordingDirectory);
try
{
screenRecording = new ScreenRecording(recordingDirectory);
if (screenRecording.IsAvailable)
{
_ = screenRecording.StartRecordingAsync();
}
else
{
screenRecording = null;
}
}
catch
{
screenRecording = null;
}
}
catch
{
// Capture setup is best-effort; never block the test on it.
}
}
/// <summary>Stop the screenshot timer and finalize the recording. Best-effort.</summary>
private async Task StopPipelineCaptureAsync()
{
try
{
screenshotTimer?.Change(Timeout.Infinite, Timeout.Infinite);
}
catch
{
}
if (screenRecording is not null)
{
try
{
await screenRecording.StopRecordingAsync();
}
catch
{
}
}
}
private void AddScreenshotsToTestResults()
{
if (screenshotDirectory is not null && Directory.Exists(screenshotDirectory))
{
foreach (var file in Directory.GetFiles(screenshotDirectory))
{
TestContext.AddResultFile(file);
}
}
}
private void AddRecordingsToTestResults()
{
if (recordingDirectory is not null && Directory.Exists(recordingDirectory))
{
foreach (var file in Directory.GetFiles(recordingDirectory, "*.mp4"))
{
TestContext.AddResultFile(file);
}
}
}
private void CleanupRecordingDirectory()
{
if (recordingDirectory is not null && Directory.Exists(recordingDirectory))
{
try
{
Directory.Delete(recordingDirectory, true);
}
catch
{
}
}
}
/// <summary>
/// Copy PowerToys <c>*.log</c> files (from both <c>%LocalAppData%</c> and <c>%LocalAppDataLow%</c>)
/// into the test results so a failed CI run carries the module logs.
/// </summary>
private void AddLogFilesToTestResults()
{
try
{
var localLow = Path.Combine(
Environment.GetEnvironmentVariable("USERPROFILE") ?? string.Empty,
"AppData", "LocalLow", "Microsoft", "PowerToys");
CopyLogFiles(localLow);
var localAppData = Path.Combine(
Environment.GetEnvironmentVariable("LOCALAPPDATA") ?? string.Empty,
"Microsoft", "PowerToys");
CopyLogFiles(localAppData);
}
catch
{
// Log collection is diagnostic-only.
}
}
private void CopyLogFiles(string sourceDir, string relativePath = "")
{
if (!Directory.Exists(sourceDir))
{
return;
}
foreach (var logFile in Directory.GetFiles(sourceDir, "*.log"))
{
try
{
var fileName = Path.GetFileName(logFile);
var prefix = string.IsNullOrEmpty(relativePath) ? string.Empty : relativePath.Replace("\\", "-") + "-";
var destination = Path.Combine(
TestContext.TestResultsDirectory ?? Path.GetTempPath(), $"{prefix}{fileName}");
File.Copy(logFile, destination, true);
TestContext.AddResultFile(destination);
}
catch
{
}
}
foreach (var subdir in Directory.GetDirectories(sourceDir))
{
var dirName = Path.GetFileName(subdir);
var newRelative = string.IsNullOrEmpty(relativePath) ? dirName : Path.Combine(relativePath, dirName);
CopyLogFiles(subdir, newRelative);
}
}
/// <summary>Find an element on the session's window. Shortcut for <c>Session.Find&lt;T&gt;</c>.</summary>
protected T Find<T>(By by, int timeoutMS = 5000)
where T : Element, new() => Session.Find<T>(by, timeoutMS);
/// <summary>Find an element by Name. Shortcut for <c>Session.Find&lt;T&gt;(By.Name(name))</c>.</summary>
protected T Find<T>(string name, int timeoutMS = 5000)
where T : Element, new() => Session.Find<T>(By.Name(name), timeoutMS);
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
screenshotTimer?.Dispose();
screenRecording?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,314 @@
// 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.
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Thin wrapper around the winappcli executable. Every public method shells out to
/// <c>winapp.exe</c>, captures stdout/stderr/exit-code, and (where requested) parses the
/// <c>--json</c> envelope using <see cref="JsonDocument"/>.
/// </summary>
/// <remarks>
/// <para>
/// Engine prerequisites: install once with <c>winget install Microsoft.winappcli</c>. The CLI
/// lands on PATH at <c>%LOCALAPPDATA%\Microsoft\WindowsApps\winapp.exe</c>.
/// </para>
/// <para>
/// All invocations set <c>WINAPP_CLI_TELEMETRY_OPTOUT=1</c> and disable update checks via
/// <c>WINAPP_CLI_UPDATE_CHECK=0</c> so the CLI never injects extra lines into stdout.
/// </para>
/// </remarks>
public static class WinappCli
{
/// <summary>Stable hint surfaced when the CLI is missing or fails — used in all error paths.</summary>
public const string InstallHint =
"winapp.exe not found. Install once with: winget install Microsoft.winappcli " +
"(or set the WINAPP_CLI_PATH environment variable to its full path).";
private static readonly Lazy<string> ExecutablePath = new(ResolveExecutable);
/// <summary>
/// Per-invocation guard. A hung <c>winapp.exe</c> call must fail fast and name the offending
/// command instead of blocking until the suite's outer timeout fires (which buries the cause).
/// Commands that pass a longer <c>-t</c> wait extend this; see <see cref="ResolveInvokeTimeout"/>.
/// </summary>
private static readonly TimeSpan DefaultInvokeTimeout = TimeSpan.FromSeconds(60);
public sealed record Result(int ExitCode, string StdOut, string StdErr, IReadOnlyList<string> Args)
{
public bool Success => ExitCode == 0;
/// <summary>
/// One-line, assertion-friendly description of a failed invocation. Format:
/// <c>"winapp ui invoke X -w 12345 -> exit 1; stderr: not found"</c>. Falls back to
/// stdout if stderr is empty.
/// </summary>
public string DescribeFailure()
{
var sb = new StringBuilder();
sb.Append("winapp ");
sb.AppendJoin(' ', Args);
sb.Append(" -> exit ").Append(ExitCode);
if (!string.IsNullOrWhiteSpace(StdErr))
{
sb.Append("; stderr: ").Append(StdErr.Trim());
}
else if (!string.IsNullOrWhiteSpace(StdOut))
{
sb.Append("; stdout: ").Append(StdOut.Trim());
}
return sb.ToString();
}
public JsonDocument ParseJson()
{
try
{
return JsonDocument.Parse(StdOut);
}
catch (JsonException ex)
{
throw new InvalidOperationException(
$"winappcli stdout was not valid JSON. {DescribeFailure()}",
ex);
}
}
}
/// <summary>
/// Returns true when <c>winapp.exe</c> resolves to a real file AND responds to
/// <c>--version</c>. Use from <c>[ClassInitialize]</c> / <c>[AssemblyInitialize]</c> /
/// <see cref="UITestBase"/> to fail the entire suite once with a clear install hint,
/// instead of letting every test produce its own opaque process-launch failure.
/// </summary>
public static bool IsAvailable()
{
if (!TryResolveExecutable(out _))
{
return false;
}
try
{
return Invoke("--version").Success;
}
catch
{
return false;
}
}
/// <summary>Run <c>winapp.exe</c> with the given arguments. Returns exit code and captured streams.</summary>
public static Result Invoke(params string[] args)
{
var psi = new ProcessStartInfo
{
FileName = ExecutablePath.Value,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
// Suppress telemetry banner and update-check notice so --json output stays clean.
psi.Environment["WINAPP_CLI_TELEMETRY_OPTOUT"] = "1";
psi.Environment["WINAPP_CLI_UPDATE_CHECK"] = "0";
foreach (var a in args)
{
psi.ArgumentList.Add(a);
}
using var p = StartWinappProcess(psi);
var stdoutTask = p.StandardOutput.ReadToEndAsync();
var stderrTask = p.StandardError.ReadToEndAsync();
var timeout = ResolveInvokeTimeout(args);
if (!p.WaitForExit((int)timeout.TotalMilliseconds))
{
try
{
p.Kill(entireProcessTree: true);
}
catch
{
// Raced with a natural exit between the wait timing out and the kill — nothing to do.
}
throw new TimeoutException(
$"winapp {string.Join(' ', args)} did not exit within {timeout.TotalSeconds:0}s and was killed.");
}
// Process exited within budget; this parameterless overload also blocks until the async
// stdout/stderr reads reach EOF, so the captured streams are complete.
p.WaitForExit();
return new Result(
p.ExitCode,
stdoutTask.GetAwaiter().GetResult(),
stderrTask.GetAwaiter().GetResult(),
args);
}
/// <summary>
/// Process-guard budget for one invocation. Defaults to <see cref="DefaultInvokeTimeout"/>; when the
/// command carries its own <c>-t</c>/<c>--timeout</c> wait in milliseconds (e.g. <c>wait-for</c>), the
/// guard is extended past that wait plus a grace margin so a legitimate long wait isn't killed early.
/// </summary>
private static TimeSpan ResolveInvokeTimeout(string[] args)
{
var budget = DefaultInvokeTimeout;
for (var i = 0; i < args.Length - 1; i++)
{
if ((string.Equals(args[i], "-t", StringComparison.Ordinal) ||
string.Equals(args[i], "--timeout", StringComparison.Ordinal)) &&
int.TryParse(args[i + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ms) &&
ms > 0)
{
var withGrace = TimeSpan.FromMilliseconds(ms) + TimeSpan.FromSeconds(30);
if (withGrace > budget)
{
budget = withGrace;
}
}
}
return budget;
}
/// <summary>Run and throw if the exit code is non-zero. Use for fire-and-forget commands.</summary>
public static Result InvokeAssertSuccess(params string[] args)
{
var r = Invoke(args);
Assert.AreEqual(0, r.ExitCode, r.DescribeFailure());
return r;
}
/// <summary>Run a <c>--json</c> command and return the parsed root <see cref="JsonElement"/>.</summary>
public static JsonElement InvokeJson(params string[] args)
{
var r = Invoke(args);
if (!r.Success)
{
// Many --json commands (search, wait-for) return exit 1 with a valid envelope on
// "no match" / "timed out". Still parse so the caller can branch on envelope fields.
try
{
using var doc = JsonDocument.Parse(r.StdOut);
return doc.RootElement.Clone();
}
catch
{
Assert.Fail($"{r.DescribeFailure()} (stdout was not JSON)");
return default;
}
}
using var ok = JsonDocument.Parse(r.StdOut);
return ok.RootElement.Clone();
}
/// <summary>
/// Locate <c>winapp.exe</c> without throwing or asserting. <see cref="IsAvailable"/> uses
/// this to probe quietly; the lazy <see cref="ResolveExecutable"/> wraps it for the
/// first real call.
/// </summary>
public static bool TryResolveExecutable(out string path)
{
// 1) Explicit override (CI / dev convenience).
var env = Environment.GetEnvironmentVariable("WINAPP_CLI_PATH");
if (!string.IsNullOrEmpty(env) && File.Exists(env))
{
path = env;
return true;
}
// 2) Standard winget install location.
var winget = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"WindowsApps",
"winapp.exe");
if (File.Exists(winget))
{
path = winget;
return true;
}
// 3) Anything on PATH.
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
foreach (var dir in pathEnv.Split(Path.PathSeparator))
{
if (string.IsNullOrWhiteSpace(dir))
{
continue;
}
try
{
var candidate = Path.Combine(dir, "winapp.exe");
if (File.Exists(candidate))
{
path = candidate;
return true;
}
}
catch
{
}
}
path = string.Empty;
return false;
}
/// <summary>
/// Start <c>winapp.exe</c>, retrying the transient launch failure that affects Windows App
/// Execution Aliases. The <c>winapp.exe</c> found on PATH is the reparse-point stub under
/// <c>%LOCALAPPDATA%\Microsoft\WindowsApps</c>; launching an alias through <c>CreateProcess</c>
/// (<c>UseShellExecute = false</c>) intermittently throws <see cref="Win32Exception"/> with
/// <c>ERROR_INVALID_PARAMETER</c> (87, "The parameter is incorrect") before the alias resolves.
/// The launch is atomic — nothing ran — so retrying with a short backoff is safe and
/// idempotent. Other Win32 errors (missing file, access denied) propagate immediately so a
/// genuine misconfiguration still fails fast.
/// </summary>
private static Process StartWinappProcess(ProcessStartInfo psi)
{
const int maxAttempts = 4;
for (int attempt = 1; ; attempt++)
{
try
{
return Process.Start(psi) ?? throw new InvalidOperationException(
$"Failed to start winapp.exe ({psi.FileName}). {InstallHint}");
}
catch (Win32Exception ex) when (ex.NativeErrorCode == 87 && attempt < maxAttempts)
{
// App Execution Alias not resolved yet — back off briefly and retry.
Thread.Sleep(100 * attempt);
}
}
}
private static string ResolveExecutable()
{
if (TryResolveExecutable(out var path))
{
return path;
}
throw new InvalidOperationException(InstallHint);
}
}

View File

@@ -0,0 +1,263 @@
// 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.
using System.Diagnostics;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Fault-tolerant window cleanup helpers. Every method swallows exceptions and returns a
/// boolean — they're designed for test <c>finally</c> blocks where a cleanup failure must
/// never mask the real test failure.
/// </summary>
/// <remarks>
/// winappcli has no <c>close</c> verb, so closing goes through Win32 <c>WM_CLOSE</c>
/// (graceful) with an optional process-kill fallback. Focus uses <c>SetForegroundWindow</c>
/// against the HWND that <see cref="WindowsFinder"/> already discovers.
/// </remarks>
public static class WindowControl
{
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessageW(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool IsWindow(IntPtr hWnd);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
private const uint WM_CLOSE = 0x0010;
private const int SW_RESTORE = 9;
/// <summary>
/// Send <c>WM_CLOSE</c> to every window owned by <paramref name="appNameOrPid"/> and wait
/// up to <paramref name="timeoutMS"/> for them to disappear. Tolerant: returns false on
/// any failure instead of throwing.
/// </summary>
public static bool TryCloseByApp(string appNameOrPid, int timeoutMS = 5_000)
{
try
{
var windows = WindowsFinder.ListByApp(appNameOrPid);
if (windows.Count == 0)
{
return true; // nothing to close
}
foreach (var w in windows)
{
TryCloseHwnd(w.Hwnd);
}
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
if (WindowsFinder.ListByApp(appNameOrPid).Count == 0)
{
return true;
}
Thread.Sleep(150);
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Send <c>WM_CLOSE</c> to every window matching <paramref name="predicate"/> on the
/// process and wait for them to disappear. Use when one process owns several windows and
/// only some should be closed (e.g. close the ColorPicker editor but leave the overlay).
/// </summary>
public static bool TryCloseByApp(string appNameOrPid, Func<WindowsFinder.WindowInfo, bool> predicate, int timeoutMS = 5_000)
{
try
{
var targets = WindowsFinder.ListByApp(appNameOrPid).Where(predicate).ToList();
if (targets.Count == 0)
{
return true;
}
foreach (var w in targets)
{
TryCloseHwnd(w.Hwnd);
}
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
if (!WindowsFinder.ListByApp(appNameOrPid).Any(predicate))
{
return true;
}
Thread.Sleep(150);
}
return false;
}
catch
{
return false;
}
}
/// <summary>
/// Bring the first window owned by <paramref name="appNameOrPid"/> to the foreground.
/// If the window is minimized it's first restored. Tolerant.
/// </summary>
public static bool TryFocusByApp(string appNameOrPid)
{
try
{
var w = WindowsFinder.ListByApp(appNameOrPid).FirstOrDefault();
if (w is null || w.Hwnd == 0)
{
return false;
}
var hwnd = new IntPtr(w.Hwnd);
if (!IsWindow(hwnd))
{
return false;
}
ShowWindow(hwnd, SW_RESTORE);
return SetForegroundWindow(hwnd);
}
catch
{
return false;
}
}
/// <summary>
/// Cleanup convenience: close every window of <paramref name="closeApp"/> (if any) and
/// bring <paramref name="focusApp"/> to the foreground. Mirrors the pattern in the legacy
/// <c>TestHelper.CleanupTest</c> (close target window → re-attach to Settings) but does
/// not throw, so it's safe to call from a test <c>finally</c>.
/// </summary>
public static void SafeCloseAndFocus(string closeApp, string focusApp, int closeTimeoutMS = 5_000)
{
TryCloseByApp(closeApp, closeTimeoutMS);
TryFocusByApp(focusApp);
}
/// <summary>
/// Force-terminate every process whose name contains <paramref name="processNameContains"/>.
/// Use only as a last resort when <see cref="TryCloseByApp(string, int)"/> failed and the
/// module's window must be gone before the next test starts.
/// </summary>
public static bool TryKillProcess(string processNameContains)
{
try
{
var hits = Process.GetProcesses()
.Where(p =>
{
try
{
return p.ProcessName.Contains(processNameContains, StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
})
.ToList();
foreach (var p in hits)
{
try
{
p.Kill(entireProcessTree: true);
}
catch
{
// Best effort.
}
finally
{
p.Dispose();
}
}
return hits.Count > 0;
}
catch
{
return false;
}
}
/// <summary>
/// Force-terminate every process whose name <b>exactly</b> equals <paramref name="exactProcessName"/>
/// (no extension, case-insensitive — the form <see cref="Process.GetProcessesByName(string)"/> accepts).
/// Prefer this over <see cref="TryKillProcess"/> for short names like "PowerToys" that are a
/// substring of unrelated processes (e.g. a "PowerToys.*.UITests" test host the run is executing
/// in). Tolerant — returns false on any failure instead of throwing.
/// </summary>
public static bool TryKillProcessByName(string exactProcessName)
{
try
{
var hits = Process.GetProcessesByName(exactProcessName);
foreach (var p in hits)
{
try
{
p.Kill(entireProcessTree: true);
}
catch
{
// Best effort.
}
finally
{
p.Dispose();
}
}
return hits.Length > 0;
}
catch
{
return false;
}
}
private static void TryCloseHwnd(long hwnd)
{
try
{
if (hwnd == 0)
{
return;
}
var handle = new IntPtr(hwnd);
if (IsWindow(handle))
{
PostMessageW(handle, WM_CLOSE, IntPtr.Zero, IntPtr.Zero);
}
}
catch
{
// Best effort.
}
}
}

View File

@@ -0,0 +1,171 @@
// 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.
using System.Drawing;
using System.Runtime.InteropServices;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>Preset window sizes for <see cref="WindowHelper.SetWindowSize(IntPtr, WindowSize)"/>.</summary>
public enum WindowSize
{
/// <summary>No size change.</summary>
UnSpecified,
/// <summary>640 x 480.</summary>
Small,
/// <summary>480 x 640.</summary>
Small_Vertical,
/// <summary>1024 x 768.</summary>
Medium,
/// <summary>768 x 1024.</summary>
Medium_Vertical,
/// <summary>1920 x 1080.</summary>
Large,
/// <summary>1080 x 1920.</summary>
Large_Vertical,
}
/// <summary>
/// Win32 window + screen helpers for scenarios winappcli can't express: resizing/positioning a
/// window, reading a screen pixel color, and querying display geometry. Window discovery itself
/// stays CLI-first (<see cref="WindowsFinder"/>; <see cref="IsWindowOpen"/>).
/// </summary>
public static class WindowHelper
{
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
private const uint SWP_NOMOVE = 0x0002;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
private const int SM_CXSCREEN = 0;
private const int SM_CYSCREEN = 1;
private const int SW_MAXIMIZE = 3;
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
[DllImport("user32.dll")]
private static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("user32.dll")]
private static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
[DllImport("gdi32.dll")]
private static extern uint GetPixel(IntPtr hdc, int x, int y);
/// <summary>True when any UIA-visible window's title contains <paramref name="titleContains"/> (CLI-based).</summary>
public static bool IsWindowOpen(string titleContains) =>
WindowsFinder.ListAll().Any(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase));
/// <summary>Resize a window to a preset <see cref="WindowSize"/> (keeps its current position).</summary>
public static void SetWindowSize(IntPtr hWnd, WindowSize size)
{
var (w, h) = Dimensions(size);
if (w > 0 && h > 0)
{
SetMainWindowSize(hWnd, w, h);
}
}
/// <summary>Resize a window to explicit width/height (keeps its current position).</summary>
public static void SetMainWindowSize(IntPtr hWnd, int width, int height) =>
SetWindowPos(hWnd, IntPtr.Zero, 0, 0, width, height, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
/// <summary>
/// Maximize a window so it fills the monitor work area and is fully on-screen. Used as the default
/// window state for tests so a module's restored (possibly small or off-screen) last window rect
/// can't hide controls such as the Settings NavigationView pane.
/// </summary>
public static void MaximizeWindow(IntPtr hWnd) => ShowWindow(hWnd, SW_MAXIMIZE);
/// <summary>(Left, Top, Right, Bottom) of the window in screen pixels.</summary>
public static (int Left, int Top, int Right, int Bottom) GetWindowBounds(IntPtr hWnd)
{
if (GetWindowRect(hWnd, out var r))
{
return (r.Left, r.Top, r.Right, r.Bottom);
}
return (0, 0, 0, 0);
}
/// <summary>Center point of the window in screen pixels.</summary>
public static (int CenterX, int CenterY) GetWindowCenter(IntPtr hWnd)
{
var (l, t, rgt, b) = GetWindowBounds(hWnd);
return (l + ((rgt - l) / 2), t + ((b - t) / 2));
}
/// <summary>Primary display size in pixels.</summary>
public static (int Width, int Height) GetDisplaySize() =>
(GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN));
/// <summary>Center of the primary display in pixels.</summary>
public static (int CenterX, int CenterY) GetScreenCenter()
{
var (w, h) = GetDisplaySize();
return (w / 2, h / 2);
}
/// <summary>Color of the on-screen pixel at (<paramref name="x"/>, <paramref name="y"/>) via GDI.</summary>
public static Color GetPixelColor(int x, int y)
{
var hdc = GetDC(IntPtr.Zero);
try
{
var pixel = GetPixel(hdc, x, y);
int r = (int)(pixel & 0x000000FF);
int g = (int)((pixel & 0x0000FF00) >> 8);
int b = (int)((pixel & 0x00FF0000) >> 16);
return Color.FromArgb(r, g, b);
}
finally
{
ReleaseDC(IntPtr.Zero, hdc);
}
}
/// <summary>On-screen pixel color at (<paramref name="x"/>, <paramref name="y"/>) as <c>#RRGGBB</c>.</summary>
public static string GetPixelColorHex(int x, int y)
{
var c = GetPixelColor(x, y);
return $"#{c.R:X2}{c.G:X2}{c.B:X2}";
}
private static (int Width, int Height) Dimensions(WindowSize size) => size switch
{
WindowSize.Small => (640, 480),
WindowSize.Small_Vertical => (480, 640),
WindowSize.Medium => (1024, 768),
WindowSize.Medium_Vertical => (768, 1024),
WindowSize.Large => (1920, 1080),
WindowSize.Large_Vertical => (1080, 1920),
_ => (0, 0),
};
}

View File

@@ -0,0 +1,155 @@
// 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.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.PowerToys.UITest.Next;
/// <summary>
/// Static helpers for discovering and attaching to windows that aren't the test's primary scope.
/// </summary>
/// <remarks>
/// Most tests target one module's main window (handled by <see cref="UITestBase"/> + <see cref="SessionHelper"/>).
/// But scenarios like "send the ColorPicker hotkey and assert the Editor pops up" need to discover
/// a brand-new window that may not exist when the test starts. These helpers wrap
/// <c>winapp ui list-windows --json</c> to find/wait for those windows by process or title.
/// </remarks>
public static class WindowsFinder
{
public sealed record WindowInfo(long Hwnd, string Title, string ProcessName, int ProcessId, string ClassName, int Width, int Height);
/// <summary>List all UIA-visible windows.</summary>
/// <remarks>
/// NOTE: winappcli's unfiltered <c>list-windows --json</c> currently omits windows that have
/// no Win32 title (e.g. the ColorPicker editor exposes its name only via UIA Name, not the
/// HWND title). Use <see cref="ListByApp"/> with a process/PID filter when you need to see
/// those — winappcli returns them in the filtered form.
/// </remarks>
public static IReadOnlyList<WindowInfo> ListAll() => Parse(WinappCli.Invoke("ui", "list-windows", "--json"));
/// <summary>
/// List UIA-visible windows belonging to <paramref name="appNameOrPid"/> (process name substring or PID).
/// Uses winappcli's <c>-a</c> filter, which works around the bug where unfiltered
/// <c>list-windows</c> drops windows without a Win32 title.
/// </summary>
public static IReadOnlyList<WindowInfo> ListByApp(string appNameOrPid) =>
Parse(WinappCli.Invoke("ui", "list-windows", "-a", appNameOrPid, "--json"));
private static IReadOnlyList<WindowInfo> Parse(WinappCli.Result r)
{
if (!r.Success || string.IsNullOrEmpty(r.StdOut))
{
return Array.Empty<WindowInfo>();
}
try
{
using var doc = JsonDocument.Parse(r.StdOut);
if (doc.RootElement.ValueKind != JsonValueKind.Array)
{
return Array.Empty<WindowInfo>();
}
var list = new List<WindowInfo>();
foreach (var w in doc.RootElement.EnumerateArray())
{
list.Add(new WindowInfo(
Hwnd: w.TryGetProperty("hwnd", out var h) && h.ValueKind == JsonValueKind.Number ? h.GetInt64() : 0,
Title: w.TryGetProperty("title", out var t) ? (t.GetString() ?? string.Empty) : string.Empty,
ProcessName: w.TryGetProperty("processName", out var pn) ? (pn.GetString() ?? string.Empty) : string.Empty,
ProcessId: w.TryGetProperty("processId", out var pid) && pid.ValueKind == JsonValueKind.Number ? pid.GetInt32() : 0,
ClassName: w.TryGetProperty("className", out var cn) ? (cn.GetString() ?? string.Empty) : string.Empty,
Width: w.TryGetProperty("width", out var ww) && ww.ValueKind == JsonValueKind.Number ? ww.GetInt32() : 0,
Height: w.TryGetProperty("height", out var hh) && hh.ValueKind == JsonValueKind.Number ? hh.GetInt32() : 0));
}
return list;
}
catch
{
return Array.Empty<WindowInfo>();
}
}
/// <summary>
/// Poll until a window matching <paramref name="predicate"/> appears, or <paramref name="timeoutMS"/>
/// elapses. Returns the window's <see cref="Session"/> wrapper on success.
/// </summary>
public static Session? WaitForWindow(Func<WindowInfo, bool> predicate, PowerToysModule attributeAs = PowerToysModule.Runner, int timeoutMS = 10_000, int pollIntervalMS = 250)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
foreach (var w in ListAll())
{
Debug.WriteLine(w.ToString());
if (predicate(w))
{
return new Session(attributeAs, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
}
}
Thread.Sleep(pollIntervalMS);
}
return null;
}
/// <summary>Convenience wrapper: wait for a window with the given title substring.</summary>
public static Session? WaitForWindowByTitle(string titleContains, int timeoutMS = 10_000)
=> WaitForWindow(w => w.Title.Contains(titleContains, StringComparison.OrdinalIgnoreCase), timeoutMS: timeoutMS);
/// <summary>
/// Wait for any window owned by a process whose name contains <paramref name="processNameContains"/>.
/// Uses winappcli's <c>-a</c> filter under the hood so untitled windows (e.g. the ColorPicker
/// editor) are discoverable — the unfiltered <c>list-windows</c> drops those.
/// </summary>
public static Session? WaitForWindowByProcess(string processNameContains, int timeoutMS = 10_000, int pollIntervalMS = 250)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
foreach (var w in ListByApp(processNameContains))
{
Debug.WriteLine(w.ToString());
return new Session(PowerToysModule.Runner, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
}
Thread.Sleep(pollIntervalMS);
}
return null;
}
/// <summary>
/// Same as <see cref="WaitForWindowByProcess"/> but filters with <paramref name="predicate"/>.
/// Use when the same process owns multiple windows (e.g. ColorPickerUI exposes both the
/// small picker overlay and the larger editor window).
/// </summary>
public static Session? WaitForWindowByApp(
string appNameOrPid,
Func<WindowInfo, bool> predicate,
int timeoutMS = 10_000,
int pollIntervalMS = 250)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
foreach (var w in ListByApp(appNameOrPid))
{
Debug.WriteLine(w.ToString());
if (predicate(w))
{
return new Session(PowerToysModule.Runner, w.Hwnd, w.Title, w.ProcessId, w.ProcessName);
}
}
Thread.Sleep(pollIntervalMS);
}
return null;
}
}

View File

@@ -30,12 +30,30 @@ namespace Microsoft.PowerToys.UITest
/// </summary>
public string GetDevelopmentPath()
{
// The test assembly normally lives in <buildRoot>\tests\<project>\<tfm>\, so the build
// output root that holds the module exe is three levels above it. When a test project is
// built with a RuntimeIdentifier (OutputType=Exe for the MTP runner) the output gains an
// extra RID subfolder (<tfm>\win-x64\ or \win-arm64\), pushing the root one level further
// up. Detect that case so the relative path stays correct in both layouts.
string prefix = IsRuntimeIdentifierOutputFolder() ? @"\..\..\..\.." : @"\..\..\..";
if (string.IsNullOrEmpty(SubDirectory))
{
return $@"\..\..\..\{ExecutableName}";
return $@"{prefix}\{ExecutableName}";
}
return $@"\..\..\..\{SubDirectory}\{ExecutableName}";
return $@"{prefix}\{SubDirectory}\{ExecutableName}";
}
// True when the executing assembly sits in a RID-specific output subfolder (e.g. ...\<tfm>\win-x64),
// which a project with a RuntimeIdentifier produces. Used to keep GetDevelopmentPath's relative
// walk-up correct whether or not the RID subfolder is present.
private static bool IsRuntimeIdentifierOutputFolder()
{
var baseDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var leaf = Path.GetFileName(baseDir);
return leaf.Equals("win-x64", StringComparison.OrdinalIgnoreCase)
|| leaf.Equals("win-arm64", StringComparison.OrdinalIgnoreCase);
}
/// <summary>

View File

@@ -362,14 +362,89 @@ namespace Microsoft.PowerToys.UITest
private void StartWindowsAppDriverApp()
{
// Reuse an already-running WinAppDriver — one started once per job by the pipeline
// ("Start WinAppDriver" step), or by an earlier test in this assembly — instead of killing
// and relaunching it. Only reuse the listener when a WinAppDriver process actually owns it,
// so a stale or unrelated process holding :4723 can't be mistaken for the driver.
var existingWinAppDriver = Process.GetProcessesByName("WinAppDriver").FirstOrDefault();
if (existingWinAppDriver is not null)
{
if (IsWinAppDriverListening())
{
SessionHelper.appDriver = existingWinAppDriver;
return;
}
existingWinAppDriver.Dispose();
}
var winAppDriverProcessInfo = new ProcessStartInfo
{
FileName = "C:\\Program Files (x86)\\Windows Application Driver\\WinAppDriver.exe",
Verb = "runas",
// WinAppDriver ends its Main with "Press ENTER to exit" + Console.ReadLine(). Under the
// Microsoft.Testing.Platform test host the child inherits a stdin that is already at EOF,
// so that read returns immediately and WinAppDriver prints "Exiting..." and dies right
// after it starts listening — which is what forced the previous launch to keep
// relaunching it (and made the very first connection racy). Redirecting stdin and NEVER
// closing the pipe makes that read block, so the server stays alive for the whole test
// process and is reused by every test in this assembly. Redirect requires
// UseShellExecute = false; the default endpoint 127.0.0.1:4723 needs no elevation (only a
// custom IP/port does, per WinAppDriver's docs), so "runas" is not needed.
UseShellExecute = false,
RedirectStandardInput = true,
CreateNoWindow = true,
};
this.ExitExe(winAppDriverProcessInfo.FileName);
SessionHelper.appDriver = Process.Start(winAppDriverProcessInfo);
// Intentionally do NOT close appDriver.StandardInput: the open pipe is exactly what blocks
// WinAppDriver's stdin read and keeps the server alive. The static appDriver reference holds
// the pipe open until the test process exits, at which point WinAppDriver shuts down cleanly.
// WinAppDriver needs a moment to open its HTTP listener on :4723. Connecting immediately races
// that startup, so wait until the port accepts a connection before returning.
WaitForWinAppDriverReady();
}
// True when something is already accepting connections on the WinAppDriver port (127.0.0.1:4723).
private static bool IsWinAppDriverListening()
{
try
{
using var client = new System.Net.Sockets.TcpClient();
client.Connect("127.0.0.1", 4723);
return client.Connected;
}
catch
{
return false;
}
}
private static void WaitForWinAppDriverReady(int timeoutMs = 30000)
{
var deadline = DateTime.UtcNow.AddMilliseconds(timeoutMs);
while (DateTime.UtcNow < deadline)
{
if (SessionHelper.appDriver is { HasExited: false } && IsWinAppDriverListening())
{
return;
}
System.Threading.Thread.Sleep(500);
}
// Surface a WinAppDriver startup failure here, with its process state, instead of letting
// it turn into a generic "connection refused" later when the first session is created.
var processState = SessionHelper.appDriver is null
? "not started"
: SessionHelper.appDriver.HasExited
? $"exited with code {SessionHelper.appDriver.ExitCode}"
: "running";
throw new TimeoutException(
$"WinAppDriver did not start listening on 127.0.0.1:4723 within {timeoutMs}ms; process state: {processState}.");
}
private void KillPowerToysProcesses()

View File

@@ -0,0 +1,10 @@
// 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.
using Microsoft.VisualStudio.TestTools.UnitTesting;
// UI tests share global desktop state — the same Settings window, the same clipboard, the same
// foreground focus. Parallel execution against shared state is a recipe for non-determinism.
// MSTest defaults to parallel-by-method inside an assembly; pin to sequential here.
[assembly: DoNotParallelize]

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.ColorPicker.UITests</RootNamespace>
<AssemblyName>ColorPicker.UITests</AssemblyName>
<!--
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
of the repo, so this test class appears in Test Explorer AND can be run via
`dotnet test` / `dotnet run` / `vstest.console.exe`.
-->
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\ColorPicker.UITests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,446 @@
// 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.
using System.Diagnostics;
using System.Text.Json;
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.ColorPicker.UITests;
/// <summary>
/// Full end-to-end Color Picker scenario, driven entirely through the Settings UI:
/// 1. From the Settings app, navigate to the Color Picker page via the utilities stack.
/// 2. On the page, toggle the module OFF and verify <c>PowerToys.ColorPickerUI</c> exits.
/// 3. Toggle it back ON and verify <c>PowerToys.ColorPickerUI</c> respawns.
/// 4. Read the activation shortcut from the page's <c>ShortcutControl</c> (the EditButton
/// exposes <c>HotkeySettings.ToString()</c> via <c>AutomationProperties.HelpText</c>).
/// 5. Clear the clipboard, move the cursor, send the shortcut chord.
/// 6. Wait for the picker overlay window and read the displayed HEX from the overlay's
/// automation-peer TextBlock (AutomationId="ColorHexAutomationPeer").
/// 7. Left-click to capture. ColorPicker writes the captured color to the clipboard.
/// 8. Read the captured value from the clipboard and assert it matches the overlay HEX.
/// 9. Wait for the editor window and assert the captured value appears in its tree.
/// </summary>
/// <remarks>
/// The overlay's visible ColorTextBlock has <c>AutomationProperties.Name="{Binding ColorName}"</c>
/// so UIA exposes the friendly color name (e.g. "White"), not the HEX. To work around that,
/// MainView.xaml carries a hidden sibling TextBlock bound to <c>ColorText</c> with
/// <c>AutomationId="ColorHexAutomationPeer"</c> — a test-only UIA hook that lets us read the
/// actually-displayed HEX value without affecting the visual layout or accessibility UX.
/// </remarks>
[TestClass]
public class ColorPickerEndToEndTests : UITestBase
{
public ColorPickerEndToEndTests()
: base(PowerToysModule.PowerToysSettings)
{
}
[TestMethod]
[TestCategory("ColorPicker")]
[TestCategory("winappcli-POC")]
public void NavigateReadShortcutActivateAndCapture()
{
try
{
RunTest();
}
finally
{
// Universal cleanup: close any leftover ColorPicker window (overlay or editor),
// then close the Settings window. Tolerant — never throws so it can't mask the
// real test failure.
WindowControl.TryCloseByApp("PowerToys.ColorPickerUI");
WindowControl.TryCloseByApp("PowerToys.Settings");
}
}
private void RunTest()
{
// -- 1. Navigate via the utilities stack on the right of the dashboard ----------------
// The Dashboard's right-side ModuleList renders each utility as a clickable SettingsCard
// whose header is a TextBlock with the module's Label (e.g. "Color Picker"). The
// SettingsCard itself isn't surfaced by name "Color Picker" in winappcli's search — only
// its inner TextBlock label is — and the TextBlock has no InvokePattern (the click is
// handled by the SettingsCard's OnSettingsCardClick).
//
// A "Color Picker" search returns 4 elements: the Quick-Access tile (Button) and its
// label (TextBlock with invokableAncestor) on the left, plus the utility-stack label
// (TextBlock) and ToggleSwitch on the right. We pick the rightmost TextBlock (largest
// X coordinate) — that's the utility-stack label — and mouse-click it (winapp ui click
// uses real mouse simulation, which triggers the ancestor SettingsCard's click).
var matches = Session.FindAll<Element>(By.Name("Color Picker"));
TestContext.WriteLine($"'Color Picker' search returned {matches.Count} elements:");
foreach (var m in matches)
{
TestContext.WriteLine($" [{m.ControlType,-10}] class='{m.ClassName}' at ({m.X},{m.Y}) {m.Width}x{m.Height} sel='{m.Selector}'");
}
var utilityItem = matches
.Where(m => m.ClassName.Equals("TextBlock", StringComparison.OrdinalIgnoreCase))
.OrderByDescending(m => m.X)
.FirstOrDefault();
Assert.IsNotNull(
utilityItem,
"Could not find a 'Color Picker' TextBlock to click. Is the dashboard visible? See element dump above.");
TestContext.WriteLine($"Clicking utility-stack 'Color Picker' TextBlock at x={utilityItem!.X}, y={utilityItem.Y}");
utilityItem.MouseClick(msPostAction: 800);
TestContext.WriteLine("Navigated to Color Picker page (clicked utility-stack item).");
// -- 2. Find the page-level enable toggle ---------------------------------------------
// After navigation, the dashboard is gone and the page's enable toggle is the only
// "Color Picker" ToggleSwitch in the tree. The ToggleSwitch wrapper pins
// ClassName="ToggleSwitch" so the search is unambiguous.
var toggle = Find<ToggleSwitch>(By.Name("Color Picker"));
var initialIsOn = toggle.IsOn;
TestContext.WriteLine($"Initial toggle state: IsOn={initialIsOn}");
try
{
// -- 3. Toggle the module OFF and verify the runner terminates ColorPickerUI -----
// If currently OFF, prime ON first so OFF→ON→OFF gives us a real lifecycle signal.
if (!toggle.IsOn)
{
toggle.Toggle(true);
Assert.IsTrue(
toggle.WaitForProperty("ToggleState", "On", timeoutMS: 5_000),
"Priming: toggle UI did not flip to On.");
Assert.IsTrue(
WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000),
"Priming: PowerToys.ColorPickerUI did not start after enabling.");
}
toggle.Toggle(false);
Assert.IsTrue(
toggle.WaitForProperty("ToggleState", "Off", timeoutMS: 5_000),
"Toggle UI did not flip to Off.");
Assert.IsTrue(
WaitForProcess("PowerToys.ColorPickerUI", expected: false, timeoutMS: 10_000),
"PowerToys.ColorPickerUI did not exit within 10s after toggling module OFF.");
TestContext.WriteLine("Toggled OFF; ColorPickerUI process exited.");
// -- 4. Toggle the module ON and verify the runner respawns ColorPickerUI -------
toggle.Toggle(true);
Assert.IsTrue(
toggle.WaitForProperty("ToggleState", "On", timeoutMS: 5_000),
"Toggle UI did not flip to On.");
Assert.IsTrue(
WaitForProcess("PowerToys.ColorPickerUI", expected: true, timeoutMS: 10_000),
"PowerToys.ColorPickerUI did not start within 10s after toggling module ON.");
TestContext.WriteLine("Toggled ON; ColorPickerUI process running.");
// -- 5. Read the activation shortcut from the UI --------------------------------
// ShortcutControl renders the current shortcut on an inner Button (x:Name="EditButton")
// whose AutomationProperties.HelpText is set to HotkeySettings.ToString() (e.g.
// "Win + Shift + C"). x:Name reflects as the UIA AutomationId in WinUI when no
// explicit AutomationId is set, so we look it up by that.
var editButton = Find<Button>(By.AccessibilityId("EditButton"));
var shortcutText = editButton.HelpText;
TestContext.WriteLine($"Activation shortcut (from EditButton HelpText): '{shortcutText}'");
Assert.IsFalse(
string.IsNullOrWhiteSpace(shortcutText),
"Could not read activation shortcut HelpText from the ShortcutControl EditButton.");
var keys = ParseShortcutText(shortcutText);
Assert.IsTrue(
keys.Length > 0,
$"Could not parse any keys from shortcut text '{shortcutText}'.");
TestContext.WriteLine($"Parsed key chord: [{string.Join(", ", keys)}]");
// -- 6. Clear the clipboard and park the cursor ---------------------------------
// ClipboardHelper.Clear runs the Clipboard call on an STA thread (required by
// System.Windows.Forms.Clipboard) and swallows any contention errors.
var seedClipboard = ClipboardHelper.GetText();
ClipboardHelper.Clear();
TestContext.WriteLine($"Cleared clipboard. (Previous content was {seedClipboard.Length} chars.)");
var screen = System.Windows.Forms.SystemInformation.PrimaryMonitorSize;
int cx = screen.Width / 2;
int cy = screen.Height / 2;
MouseHelper.MoveTo(cx, cy);
TestContext.WriteLine($"Cursor parked at ({cx}, {cy}) — primary screen center.");
// -- 7+8. Activate via the shortcut, then wait for the picker overlay ------------
// The overlay (ColorPickerUI's MainWindow) is a small, LAYERED, transparent, topmost,
// no-taskbar window. Once activated it stays visible and follows the cursor until a
// click/Esc, so normally it shows immediately and winapp enumerates it WITHOUT any
// cursor movement (the common path locally and on most agents). Intermittently on a
// slow/loaded agent, winapp lists ZERO ColorPickerUI windows even though the runner
// logged the hotkey firing and ColorPicker activated — i.e. the overlay never reached a
// UIA-visible, on-screen state. Two mitigations, applied per attempt:
// * Poll patiently BEFORE re-sending: re-issuing the chord runs
// StartUserSession -> EndUserSession, which HIDES then re-shows the overlay, so the
// old 2s re-send cadence churned the window and a slow winapp poll kept missing it.
// * Reposition the cursor once mid-wait: ColorPicker re-positions + re-renders the
// overlay at the live cursor, recovering it if it landed off-screen/uncomposited.
// We still retry because the very first chord can be lost if the runner hasn't finished
// arming its WH_KEYBOARD_LL hook. The overlay is ~120x64 (vs the ~660x570 editor), so
// filter by size; the cursor settles on a stable pixel for the later HEX read + click.
const int activationAttempts = 3;
Session? overlay = null;
for (int attempt = 1; attempt <= activationAttempts && overlay is null; attempt++)
{
TestContext.WriteLine($"Sending activation chord [{string.Join(", ", keys)}] (attempt {attempt}/{activationAttempts}).");
KeyboardHelper.SendKeys(keys);
overlay = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
if (overlay is null)
{
// Recovery kick: nudge the overlay to a fresh on-screen spot, then keep polling
// before re-sending (which would hide/re-show it and restart the churn).
MouseHelper.MoveTo(cx + 60, cy + 60);
overlay = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI", w => w.Width < 300 && w.Height < 200, timeoutMS: 2_500);
}
}
if (overlay is null)
{
var dump = string.Join(
Environment.NewLine,
WindowsFinder.ListByApp("PowerToys.ColorPickerUI")
.Select(w => $" hwnd={w.Hwnd} title='{w.Title}' class='{w.ClassName}' size={w.Width}x{w.Height}"));
Assert.Fail(
$"Picker overlay did not appear after {activationAttempts} shortcut attempts." + Environment.NewLine +
" The hotkey DID reach the runner (it logs 'ColorPicker hotkey is invoked') and ColorPicker" + Environment.NewLine +
" activated, so this is the overlay (a small layered/transparent/topmost window) failing to" + Environment.NewLine +
" become UIA-visible/on-screen on this agent — a rendering/enumeration issue, not input." + Environment.NewLine +
" Current ColorPickerUI windows:" + Environment.NewLine +
(dump.Length > 0 ? dump : " (none)"));
}
TestContext.WriteLine($"Picker overlay appeared: hwnd={overlay!.WindowHandle}");
// -- 9. Read the displayed HEX from the overlay's automation-peer TextBlock -----
// The peer is a Visibility=Visible, Opacity=0 TextBlock added to MainView.xaml
// specifically so UIA-driven tests can read the live HEX value. It is bound to
// the same `ColorText` source as the visible TextBlock, so it always matches
// what the user sees.
string overlayHex = string.Empty;
try
{
var peer = overlay.Find(By.AccessibilityId("ColorHexAutomationPeer"), timeoutMS: 2_000);
overlayHex = peer.Name;
TestContext.WriteLine($"Overlay HEX (from automation peer): '{overlayHex}'");
}
catch (Exception ex)
{
TestContext.WriteLine($"Could not read ColorHexAutomationPeer: {ex.Message}");
}
Assert.IsFalse(
string.IsNullOrEmpty(overlayHex),
"Failed to read the overlay's HEX value from the ColorHexAutomationPeer TextBlock.");
// -- 10. Click to capture; ColorPicker writes the configured format to clipboard
MouseHelper.LeftClick();
TestContext.WriteLine("Sent left-click to capture color.");
var capturedColor = ClipboardHelper.WaitForText(ignoredValue: string.Empty, timeoutMS: 3_000);
Assert.IsFalse(
string.IsNullOrEmpty(capturedColor),
"Nothing was written to the clipboard within 3s after the click. " +
"Did the picker actually capture? (Check that left-click is mapped to a 'PickColor' action.)");
TestContext.WriteLine($"Captured color (clipboard): '{capturedColor}'");
// Cross-check: the clipboard value should be the same HEX the overlay was showing.
// Both come from `ColorText` in MainViewModel, just routed differently (overlay
// binding vs. ColorPickerHelper.CopyToClipboard on Picker_MouseDown).
Assert.IsTrue(
ContainsIgnoringHash(capturedColor, overlayHex) || ContainsIgnoringHash(overlayHex, capturedColor),
$"Overlay HEX '{overlayHex}' and clipboard '{capturedColor}' don't match.");
TestContext.WriteLine("Overlay HEX matches clipboard value.");
// -- 11. Wait for the editor window ---------------------------------------------
var editor = WindowsFinder.WaitForWindowByApp(
"PowerToys.ColorPickerUI",
w => w.Width > 300 && w.Height > 300,
timeoutMS: 10_000);
if (editor is null)
{
var dump = string.Join(
Environment.NewLine,
WindowsFinder.ListByApp("PowerToys.ColorPickerUI")
.Select(w => $" hwnd={w.Hwnd} title='{w.Title}' class='{w.ClassName}' size={w.Width}x{w.Height}"));
Assert.Fail(
"ColorPicker editor window did not appear within 10s after the click." + Environment.NewLine +
" Current ColorPickerUI windows:" + Environment.NewLine +
(dump.Length > 0 ? dump : " (none)"));
}
TestContext.WriteLine($"Editor window: hwnd={editor!.WindowHandle} title='{editor.WindowTitle}'");
// -- 12. Find the captured color inside the editor's tree ------------------------
// From ColorEditorView.xaml the format list is populated from `ColorRepresentations`.
// Each format renders as a ColorFormatControl (DataItem in the UIA tree) that
// contains a TextBox holding the formatted color string. The captured clipboard
// value will be ONE of those formats — we just need to find any element whose Name
// or Value contains it.
var tree = editor.Inspect(depth: 12);
var values = new List<(string Type, string Name, string Value)>();
WalkElements(tree, values);
TestContext.WriteLine($"Editor exposed {values.Count} elements. First 40:");
foreach (var v in values.Take(40))
{
TestContext.WriteLine($" [{v.Type,-12}] name='{v.Name}' value='{v.Value}'");
}
Assert.IsTrue(values.Count > 0, "Editor reported no readable elements via inspect --json.");
// Match: find any element whose Name or Value contains the clipboard text
// case-insensitively. If the clipboard had a '#' prefix (e.g. "#FFFFFF") and the
// editor renders without it, also try the bare-hex form.
var needle = capturedColor.Trim();
var needleBareHex = needle.TrimStart('#');
var match = values.FirstOrDefault(v =>
v.Name.Contains(needle, StringComparison.OrdinalIgnoreCase) ||
v.Value.Contains(needle, StringComparison.OrdinalIgnoreCase) ||
(needleBareHex.Length > 0 &&
(v.Name.Contains(needleBareHex, StringComparison.OrdinalIgnoreCase) ||
v.Value.Contains(needleBareHex, StringComparison.OrdinalIgnoreCase))));
if (string.IsNullOrEmpty(match.Name) && string.IsNullOrEmpty(match.Value))
{
Assert.Fail(
$"Captured color '{capturedColor}' not found in editor tree." + Environment.NewLine +
" See element dump above.");
}
TestContext.WriteLine(
$"MATCH: captured '{capturedColor}' found in editor element [{match.Type}] Name='{match.Name}' Value='{match.Value}'");
}
finally
{
// Restore the toggle to its initial state regardless of pass/fail. Best-effort so
// a cleanup failure can't mask the real test failure.
try
{
if (toggle.IsOn != initialIsOn)
{
toggle.Toggle(initialIsOn);
}
}
catch
{
}
}
}
/// <summary>
/// Case-insensitive substring comparison that ignores a leading <c>#</c> on either side.
/// Used to cross-check the overlay HEX against the clipboard value when only one of them
/// carries the prefix.
/// </summary>
private static bool ContainsIgnoringHash(string haystack, string needle)
{
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
{
return false;
}
return haystack.TrimStart('#').Contains(needle.TrimStart('#'), StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Parse a UI-rendered shortcut string like <c>"Win + Shift + C"</c> into the
/// <see cref="Key"/> sequence the harness's keyboard helper expects. Matches the parser
/// pattern used by <c>ScreenRuler.UITests/TestHelper.cs</c>.
/// </summary>
private static Key[] ParseShortcutText(string shortcutText)
{
var separators = new[] { " + ", "+", " " };
var parts = shortcutText.Split(separators, StringSplitOptions.RemoveEmptyEntries);
var keys = new List<Key>();
foreach (var raw in parts)
{
var part = raw.Trim().ToLowerInvariant();
Key? key = part switch
{
"win" or "windows" => Key.LWin,
"ctrl" or "control" => Key.Ctrl,
"shift" => Key.Shift,
"alt" => Key.Alt,
_ when part.Length == 1 && part[0] >= 'a' && part[0] <= 'z' =>
(Key)Enum.Parse(typeof(Key), part.ToUpperInvariant()),
_ => null,
};
if (key.HasValue)
{
keys.Add(key.Value);
}
}
return keys.ToArray();
}
/// <summary>Poll <see cref="Process.GetProcessesByName"/> until presence matches <paramref name="expected"/>.</summary>
private static bool WaitForProcess(string name, bool expected, int timeoutMS)
{
var deadline = DateTime.UtcNow + TimeSpan.FromMilliseconds(timeoutMS);
while (DateTime.UtcNow < deadline)
{
var running = Process.GetProcessesByName(name).Length > 0;
if (running == expected)
{
return true;
}
Thread.Sleep(250);
}
return false;
}
/// <summary>
/// Walk the nested <c>inspect --json</c> tree and collect every element with a non-empty
/// name or value. Output shape (from winappcli):
/// <c>{ "windows": [{ "elements": [{ "type", "name", "value", "children": [...] }] }] }</c>.
/// </summary>
private static void WalkElements(JsonElement root, List<(string Type, string Name, string Value)> sink)
{
if (!root.TryGetProperty("windows", out var windows) || windows.ValueKind != JsonValueKind.Array)
{
return;
}
foreach (var w in windows.EnumerateArray())
{
if (w.TryGetProperty("elements", out var els) && els.ValueKind == JsonValueKind.Array)
{
foreach (var el in els.EnumerateArray())
{
Walk(el, sink);
}
}
}
}
private static void Walk(JsonElement el, List<(string Type, string Name, string Value)> sink)
{
var type = el.TryGetProperty("type", out var t) ? (t.GetString() ?? string.Empty) : string.Empty;
var name = el.TryGetProperty("name", out var n) ? (n.GetString() ?? string.Empty) : string.Empty;
var value = el.TryGetProperty("value", out var v) ? (v.GetString() ?? string.Empty) : string.Empty;
if (!string.IsNullOrEmpty(name) || !string.IsNullOrEmpty(value))
{
sink.Add((type, name, value));
}
if (el.TryGetProperty("children", out var ch) && ch.ValueKind == JsonValueKind.Array)
{
foreach (var c in ch.EnumerateArray())
{
Walk(c, sink);
}
}
}
}

View File

@@ -26,6 +26,20 @@
</Border.Effect>-->
<Grid>
<!--
UIA test hook. Mirrors the displayed HEX value (ColorText) into a
transparent TextBlock with a stable AutomationId so automated UI tests
can read the picker's current color without depending on the visible
TextBlock, whose AutomationProperties.Name is bound to ColorName (the
friendly name) for screen-reader UX and therefore masks the HEX from UIA.
-->
<TextBlock
x:Name="ColorHexAutomationPeer"
AutomationProperties.AutomationId="ColorHexAutomationPeer"
IsHitTestVisible="False"
Opacity="0"
Text="{Binding ColorText}" />
<!-- only color format - one line -->
<Grid Margin="2" Visibility="{Binding ShowColorName, Converter={StaticResource bool2InvertedVisibilityConverter}}">
<Grid.ColumnDefinitions>

View File

@@ -1,16 +0,0 @@
// 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.
using Microsoft.PowerToys.UITest;
namespace Microsoft.ColorPicker.UITests
{
public class ColorPickerUITest : UITestBase
{
public ColorPickerUITest()
: base(PowerToysModule.Runner)
{
}
}
}

View File

@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<ProjectGuid>{6880CE86-5B71-4440-9795-79A325F95747}</ProjectGuid>
<RootNamespace>Microsoft.ColorPicker.UITests</RootNamespace>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<OutputType>Library</OutputType>
<!-- This is a UI test, so don't run as part of MSBuild -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\UITests-ColorPicker\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Appium.WebDriver" />
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>Microsoft.Settings.UITests</RootNamespace>
<AssemblyName>Settings.UITests</AssemblyName>
<!--
Microsoft.Testing.Platform: same modern runner Directory.Build.props enables for the rest
of the repo, so this test class appears in Test Explorer AND can be run via
`dotnet test` / `dotnet run` / `vstest.console.exe`.
-->
<IsTestingPlatformApplication>true</IsTestingPlatformApplication>
<EnableMSTestRunner>true</EnableMSTestRunner>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- UI tests need a live desktop; never run them as part of MSBuild. -->
<RunVSTest>false</RunVSTest>
</PropertyGroup>
<!-- Stage the built test app under <Platform>\<Configuration>\tests\ so the UI-tests build
pipeline (CopyFiles glob **/<plat>/<config>/tests/**) picks it up, matching the other
*.UITests projects. Without this it builds to bin\ and is never staged into the artifact. -->
<PropertyGroup>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\Settings.UITests\</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\common\UITestAutomation.Next\UITestAutomation.Next.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,153 @@
// 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.
using System.Reflection;
using Microsoft.PowerToys.UITest.Next;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.Settings.UITests;
/// <summary>
/// Smoke test that drives the Settings shell via winappcli and asserts that clicking every
/// <c>NavigationViewItem</c> leaves the process alive.
/// </summary>
/// <remarks>
/// <para>
/// Inspired by <see href="https://github.com/microsoft/PowerToys/pull/48414"/>. Uses our
/// <see cref="UITestAutomation.Next"/> harness instead of the PR's bare wrapper so the same
/// surface (Find/Click/By/Element) works across all module tests.
/// </para>
/// <para>
/// Inherits <see cref="UITestBase"/> with <see cref="UITestBase.ReuseScopeAcrossTests"/> on, so a
/// single Settings window is reused across every nav-item case (one launch per class, not per test)
/// while still getting the framework's unified failure-media capture for free — no test-local
/// screenshot code. One method per nav item via <c>[DynamicData]</c> gives a discrete pass/fail per
/// item in Test Explorer / pipeline reports — if <c>FancyZonesNavItem</c> regresses, the report names it.
/// </para>
/// <para>
/// Selectors are AutomationIds straight from
/// <c>src/settings-ui/Settings.UI/SettingsXAML/Views/ShellPage.xaml</c>; they don't change with
/// the user's MUI language so the test stays localization-independent. Parent groups
/// (<c>SystemToolsNavItem</c>, <c>WindowingAndLayoutsNavItem</c>, <c>InputOutputNavItem</c>,
/// <c>FileManagementNavItem</c>, <c>AdvancedNavItem</c>) have <c>SelectsOnInvoked="False"</c>
/// and only expand on invoke; our <see cref="Element.Click"/> tries InvokePattern \u2192
/// TogglePattern \u2192 SelectionItemPattern \u2192 ExpandCollapsePattern in order so the same
/// call works for both navigation-y leaves and expand-y groups.
/// </para>
/// </remarks>
[TestClass]
public sealed class SettingsNavigationSmokeTests : UITestBase
{
// (ParentGroupSlug | null, NavItemSlug). Mirrors the live hierarchy in ShellPage.xaml.
// Footer items (OOBE/WhatIsNew/Feedback/Close) are intentionally excluded \u2014 those use
// Tapped handlers that open dialogs / external pages and aren't part of the in-shell
// navigation surface we're guarding against FailFast.
private static readonly NavigationCase[] NavigationItems = new[]
{
// Top-level
new NavigationCase(null, "DashboardNavItem"),
new NavigationCase(null, "GeneralNavItem"),
// System tools
new NavigationCase("SystemToolsNavItem", "AdvancedPasteNavItem"),
new NavigationCase("SystemToolsNavItem", "AwakeNavItem"),
new NavigationCase("SystemToolsNavItem", "CmdPalNavItem"),
new NavigationCase("SystemToolsNavItem", "ColorPickerNavItem"),
new NavigationCase("SystemToolsNavItem", "LightSwitchNavItem"),
new NavigationCase("SystemToolsNavItem", "PowerLauncherNavItem"),
new NavigationCase("SystemToolsNavItem", "ScreenRulerNavItem"),
new NavigationCase("SystemToolsNavItem", "ShortcutGuideNavItem"),
new NavigationCase("SystemToolsNavItem", "TextExtractorNavItem"),
new NavigationCase("SystemToolsNavItem", "ZoomItNavItem"),
// Windowing and layouts
new NavigationCase("WindowingAndLayoutsNavItem", "AlwaysOnTopNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "CropAndLockNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "FancyZonesNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "GrabAndMoveNavItem"),
new NavigationCase("WindowingAndLayoutsNavItem", "WorkspacesNavItem"),
// Input / Output
new NavigationCase("InputOutputNavItem", "KeyboardManagerNavItem"),
new NavigationCase("InputOutputNavItem", "MouseUtilitiesNavItem"),
new NavigationCase("InputOutputNavItem", "MouseWithoutBordersNavItem"),
new NavigationCase("InputOutputNavItem", "PowerDisplayNavItem"),
new NavigationCase("InputOutputNavItem", "QuickAccentNavItem"),
// File management
new NavigationCase("FileManagementNavItem", "PowerPreviewNavItem"),
new NavigationCase("FileManagementNavItem", "FileLocksmithNavItem"),
new NavigationCase("FileManagementNavItem", "ImageResizerNavItem"),
new NavigationCase("FileManagementNavItem", "NewPlusNavItem"),
new NavigationCase("FileManagementNavItem", "PeekNavItem"),
new NavigationCase("FileManagementNavItem", "PowerRenameNavItem"),
// Advanced
new NavigationCase("AdvancedNavItem", "CmdNotFoundNavItem"),
new NavigationCase("AdvancedNavItem", "EnvironmentVariablesNavItem"),
new NavigationCase("AdvancedNavItem", "HostsNavItem"),
new NavigationCase("AdvancedNavItem", "RegistryPreviewNavItem"),
};
private const string ScopeProcessName = "PowerToys.Settings";
private const PowerToysModule Scope = PowerToysModule.PowerToysSettings;
public SettingsNavigationSmokeTests()
: base(Scope)
{
}
// Reuse one Settings window across all nav-item cases (no per-test relaunch); the framework
// still captures failure media per test and stops Settings once the class finishes.
protected override bool ReuseScopeAcrossTests => true;
public static IEnumerable<object[]> NavigationCases()
{
foreach (var c in NavigationItems)
{
yield return new object[] { c.ParentGroupSlug ?? string.Empty, c.NavItemSlug };
}
}
public static string GetNavCaseDisplayName(MethodInfo _, object[] data)
{
var parent = (string)data[0];
var item = (string)data[1];
return string.IsNullOrEmpty(parent) ? item : $"{parent} -> {item}";
}
[TestMethod]
[TestCategory("Settings")]
[TestCategory("winappcli-POC")]
[DynamicData(nameof(NavigationCases), DynamicDataDisplayName = nameof(GetNavCaseDisplayName))]
public void NavigationItem_NavigatesWithoutCrashing(string parentGroupSlug, string navItemSlug)
{
// The Settings window is shared across the class, so a parent group may already be expanded
// from a previous case. Only expand it when the child isn't already in the tree — clicking
// an already-expanded group would collapse it.
if (!string.IsNullOrEmpty(parentGroupSlug) && !Session.Has(By.AccessibilityId(navItemSlug), 500))
{
Find<NavigationViewItem>(By.AccessibilityId(parentGroupSlug)).Click();
}
// Child item is only in the visual tree once its parent is expanded; Find polls for up to
// timeoutMS so the expand animation doesn't race us.
Find<NavigationViewItem>(By.AccessibilityId(navItemSlug), timeoutMS: 5_000).Click();
// Brief settle so any unhandled exception in the page constructor or navigation handler
// has time to land in RoFailFast.
Thread.Sleep(250);
// Check by process name, not by launcher PID. Settings is single-instance: the EXE the
// framework started often exits cleanly after handing off to an existing instance, so the
// actual window may be owned by a different PID than the one we launched.
Assert.IsTrue(
SessionHelper.IsRunning(Scope),
$"No {ScopeProcessName} process remains after invoking '{navItemSlug}'. " +
"Likely a navigation FailFast regression \u2014 see ShellViewModel.Frame_NavigationFailed.");
}
private readonly record struct NavigationCase(string? ParentGroupSlug, string NavItemSlug);
}