mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 07:59:36 +02:00
Compare commits
32 Commits
main
...
gleb/ui-te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e5be0e75a | ||
|
|
c0ff2214d5 | ||
|
|
8900ae0835 | ||
|
|
8dcd2d48cd | ||
|
|
9c9566d1fd | ||
|
|
446009daba | ||
|
|
96d8d70636 | ||
|
|
f13dd6eb61 | ||
|
|
5c487a5be3 | ||
|
|
0b0a698fed | ||
|
|
858b9ea2db | ||
|
|
7ead88631c | ||
|
|
40044ae268 | ||
|
|
892fe244d7 | ||
|
|
0a93c4d179 | ||
|
|
eea70294ec | ||
|
|
6183b99020 | ||
|
|
bfd089fc45 | ||
|
|
1208c019c1 | ||
|
|
45da84b377 | ||
|
|
6ddfbdee48 | ||
|
|
715e1bd0dd | ||
|
|
5740651c63 | ||
|
|
e8df5a7c84 | ||
|
|
ba6612f375 | ||
|
|
d7f6f83b71 | ||
|
|
294bbcc029 | ||
|
|
63bd903d2d | ||
|
|
7ee6bc6500 | ||
|
|
cf3d132a8f | ||
|
|
c37eaf00c3 | ||
|
|
3bc472bcda |
2
.github/actions/spell-check/expect.txt
vendored
2
.github/actions/spell-check/expect.txt
vendored
@@ -2084,6 +2084,8 @@ wifi
|
||||
wikimedia
|
||||
wikipedia
|
||||
winapi
|
||||
winapp
|
||||
winappcli
|
||||
winappsdk
|
||||
windir
|
||||
WINDOWCREATED
|
||||
|
||||
71
.pipelines/InstallWinAppCli.ps1
Normal file
71
.pipelines/InstallWinAppCli.ps1
Normal 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)."
|
||||
}
|
||||
@@ -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)'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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\\**"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!--
|
||||
|
||||
49
src/common/UITestAutomation.Next/By.cs
Normal file
49
src/common/UITestAutomation.Next/By.cs
Normal 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 "<text>"</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}";
|
||||
}
|
||||
75
src/common/UITestAutomation.Next/ClipboardHelper.cs
Normal file
75
src/common/UITestAutomation.Next/ClipboardHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
131
src/common/UITestAutomation.Next/DisplayHelper.cs
Normal file
131
src/common/UITestAutomation.Next/DisplayHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/common/UITestAutomation.Next/Element/Button.cs
Normal file
13
src/common/UITestAutomation.Next/Element/Button.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
31
src/common/UITestAutomation.Next/Element/CheckBox.cs
Normal file
31
src/common/UITestAutomation.Next/Element/CheckBox.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
52
src/common/UITestAutomation.Next/Element/ComboBox.cs
Normal file
52
src/common/UITestAutomation.Next/Element/ComboBox.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/common/UITestAutomation.Next/Element/Custom.cs
Normal file
17
src/common/UITestAutomation.Next/Element/Custom.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
390
src/common/UITestAutomation.Next/Element/Element.cs
Normal file
390
src/common/UITestAutomation.Next/Element/Element.cs
Normal 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 <slug></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 <slug> --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";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
14
src/common/UITestAutomation.Next/Element/Pane.cs
Normal file
14
src/common/UITestAutomation.Next/Element/Pane.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
31
src/common/UITestAutomation.Next/Element/RadioButton.cs
Normal file
31
src/common/UITestAutomation.Next/Element/RadioButton.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
41
src/common/UITestAutomation.Next/Element/Slider.cs
Normal file
41
src/common/UITestAutomation.Next/Element/Slider.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
17
src/common/UITestAutomation.Next/Element/Tab.cs
Normal file
17
src/common/UITestAutomation.Next/Element/Tab.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
20
src/common/UITestAutomation.Next/Element/TextBlock.cs
Normal file
20
src/common/UITestAutomation.Next/Element/TextBlock.cs
Normal 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();
|
||||
}
|
||||
46
src/common/UITestAutomation.Next/Element/TextBox.cs
Normal file
46
src/common/UITestAutomation.Next/Element/TextBox.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/common/UITestAutomation.Next/Element/Thumb.cs
Normal file
17
src/common/UITestAutomation.Next/Element/Thumb.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
32
src/common/UITestAutomation.Next/Element/ToggleSwitch.cs
Normal file
32
src/common/UITestAutomation.Next/Element/ToggleSwitch.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
13
src/common/UITestAutomation.Next/Element/Window.cs
Normal file
13
src/common/UITestAutomation.Next/Element/Window.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
71
src/common/UITestAutomation.Next/ElevationHelper.cs
Normal file
71
src/common/UITestAutomation.Next/ElevationHelper.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
40
src/common/UITestAutomation.Next/EnvironmentConfig.cs
Normal file
40
src/common/UITestAutomation.Next/EnvironmentConfig.cs
Normal 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;
|
||||
}
|
||||
162
src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md
Normal file
162
src/common/UITestAutomation.Next/FRAMEWORK-PARITY-PLAN.md
Normal 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.
|
||||
204
src/common/UITestAutomation.Next/KeyboardHelper.cs
Normal file
204
src/common/UITestAutomation.Next/KeyboardHelper.cs
Normal 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;
|
||||
}
|
||||
207
src/common/UITestAutomation.Next/ModuleConfigData.cs
Normal file
207
src/common/UITestAutomation.Next/ModuleConfigData.cs
Normal 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><root>\<plat>\<cfg></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;
|
||||
}
|
||||
}
|
||||
104
src/common/UITestAutomation.Next/MonitorInfo.cs
Normal file
104
src/common/UITestAutomation.Next/MonitorInfo.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
152
src/common/UITestAutomation.Next/MouseHelper.cs
Normal file
152
src/common/UITestAutomation.Next/MouseHelper.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
128
src/common/UITestAutomation.Next/ScreenCapture.cs
Normal file
128
src/common/UITestAutomation.Next/ScreenCapture.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
340
src/common/UITestAutomation.Next/ScreenRecording.cs
Normal file
340
src/common/UITestAutomation.Next/ScreenRecording.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
421
src/common/UITestAutomation.Next/Session.cs
Normal file
421
src/common/UITestAutomation.Next/Session.cs
Normal 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 <hwnd></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 <name|pid></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 <app></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);
|
||||
}
|
||||
380
src/common/UITestAutomation.Next/SessionHelper.cs
Normal file
380
src/common/UITestAutomation.Next/SessionHelper.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
105
src/common/UITestAutomation.Next/SettingsConfigHelper.cs
Normal file
105
src/common/UITestAutomation.Next/SettingsConfigHelper.cs
Normal 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\<module>\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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
544
src/common/UITestAutomation.Next/UITestBase.cs
Normal file
544
src/common/UITestAutomation.Next/UITestBase.cs
Normal 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<T></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<T></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<T>(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);
|
||||
}
|
||||
}
|
||||
314
src/common/UITestAutomation.Next/WinappCli.cs
Normal file
314
src/common/UITestAutomation.Next/WinappCli.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
263
src/common/UITestAutomation.Next/WindowControl.cs
Normal file
263
src/common/UITestAutomation.Next/WindowControl.cs
Normal 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
171
src/common/UITestAutomation.Next/WindowHelper.cs
Normal file
171
src/common/UITestAutomation.Next/WindowHelper.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
155
src/common/UITestAutomation.Next/Windows.cs
Normal file
155
src/common/UITestAutomation.Next/Windows.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
10
src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs
Normal file
10
src/modules/colorPicker/ColorPicker.UITests/AssemblyInfo.cs
Normal 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]
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
42
src/settings-ui/Settings.UITests/Settings.UITests.csproj
Normal file
42
src/settings-ui/Settings.UITests/Settings.UITests.csproj
Normal 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>
|
||||
153
src/settings-ui/Settings.UITests/SettingsNavigationSmokeTests.cs
Normal file
153
src/settings-ui/Settings.UITests/SettingsNavigationSmokeTests.cs
Normal 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user